Compare commits

...

119 Commits

Author SHA1 Message Date
rohitwaghchaure
6341bb6d0f feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127)
* feat(stock): repost only GL entries from the value comparison report

Adds a "Create GL Reposting Entries" button to the Stock and Account Value
Comparison report, next to the existing "Create Reposting Entries" which is
unchanged. It queues Repost Item Valuation entries with
`repost_only_accounting_ledgers` set, so the General and Payment Ledger are
rebuilt for the selected vouchers while the stock ledgers and valuation rates
are left untouched. This is for the case where stock valuation is already
correct and only the accounting ledger has drifted, which avoids paying for a
full revaluation.

Rows of ledger type "GL Entry" are rejected: they have accounting entries but
no stock ledger entries to rebuild them from, so a GL-only repost would just
wipe their GL. The same restriction is enforced on Repost Item Valuation for
callers outside the report.

Repeated selections are deduplicated, and vouchers that already have a queued
or in-progress GL-only repost are skipped.

* feat(stock): bound GL reposting by a From Date, skip non-stock rows

* fix: batch the pending GL repost lookup and index it on existing sites

(cherry picked from commit c055faabc1)

# Conflicts:
#	erpnext/patches.txt
#	erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
2026-09-18 11:32:30 +00:00
Pandiyan P
4bbd07944d fix(stock): respect manual picking when mapping sales orders (#59170) 2026-09-18 16:31:56 +05:30
Pandiyan P
622b7e0caa fix(stock): forward company to outward batch lookup (#59160) 2026-09-18 14:36:49 +05:30
Khushi Rawat
3d1ec937cf Merge pull request #59142 from aerele/fix/shipping-rule-account-company
fix: validate shipping rule account company
2026-09-18 14:06:59 +05:30
Pandiyan P
6cc500e28d fix(stock): continue putaway allocation after undersized whole-uom rules (#59155) 2026-09-18 07:44:21 +00:00
Khushi Rawat
40ab41278e Merge pull request #59081 from aerele/fix/bank-reconciliation-date-validation
fix(accounts): validate bank reconciliation date ranges
2026-09-18 13:07:24 +05:30
mergify[bot]
4197932f60 perf(stock): chunk the serial and batch entry backfill patch (backport #59076) (#59151)
perf(stock): chunk the serial and batch entry backfill patch (#59076)

* perf(stock): chunk the serial and batch entry backfill patch

* fix(stock): support postgres in the serial and batch entry backfill patch

(cherry picked from commit 86fe0c1f4b)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-18 11:47:42 +05:30
rohitwaghchaure
aebb160e76 feat: mark update_serial_batch_entries patch as never skippable (#59147) 2026-09-17 16:52:16 +00:00
Sudharsanan Ashok
5d5eeb5e02 fix(manufacturing): include the mrp bucket that ends on to_date (#59143) 2026-09-17 13:21:10 +00:00
pandiyan
022ee73d78 test: cover shipping rule account company validation 2026-09-17 18:31:38 +05:30
pandiyan
340feb94a1 fix: validate shipping rule account company 2026-09-17 18:31:38 +05:30
Mihir Kandoi
4f312fcba4 docs(postgres): a representative row must not be picked by Min(name) (#59135) 2026-09-17 08:40:46 +00:00
Mihir Kandoi
f3c2a836eb fix(stock): break the disassembly representative tie on a date, not a name (#59131) 2026-09-17 08:39:01 +00:00
Mihir Kandoi
f97660d10e fix(manufacturing): take the job card secondary UOM off the representative line (#59129) 2026-09-17 08:35:36 +00:00
Mihir Kandoi
00b7e6e9eb fix(stock): reserve product bundle components from a Pick List (#59134) 2026-09-17 13:58:02 +05:30
Mihir Kandoi
0f66c41819 fix(buying): take the requested-item UOM pair off one line (#59132) 2026-09-17 08:26:34 +00:00
Mihir Kandoi
2aab7f4f72 fix(accounts): take the POS summary labels off one invoice, not a text sort (#59130) 2026-09-17 08:23:12 +00:00
Mihir Kandoi
228bbe845f fix(controllers): report a Prospect quotation's territory (#59133) 2026-09-17 08:21:59 +00:00
Pandiyan P
60913b722a fix: retain bom components without warehouse stock (#59116)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-17 11:08:07 +05:30
Mihir Kandoi
0e31182dca perf(stock): skip the negative batch scan when nothing is negative (#59061) 2026-09-17 11:07:27 +05:30
Mihir Kandoi
ded6df3614 fix(selling): reset ordered_qty when a cancelled Sales Order is unlinked from its Purchase Orders (#59120) 2026-09-17 11:05:11 +05:30
Mihir Kandoi
cb409d710a refactor(manufacturing): share the bin subquery in BOM Stock Analysis (#59121) 2026-09-17 10:59:02 +05:30
Abdeali Chharchhodawala
4e3e301c90 fix: formula evaluation and line reference validation for FRT (#59084)
* fix: use one formula environment in validator and engine

* test: cover shared formula environment

* fix: use distinct dummy values when test-evaluating formulas

* fix: reject line references that can't be used in a formula

* fix: drop the undefined-reference check

* fix: normalise line references before validating

* fix: normalise formulas on save instead of during validation

* fix: escape validation messages where they are rendered

* fix: strip the formula in the engine instead of relying on the validator

* fix: ignore division by zero when test-evaluating formulas
2026-09-17 10:47:37 +05:30
Diptanil Saha
863aa45e51 fix: add permission checks and record-level scoping to whitelisted methods (#59100) 2026-09-17 10:30:47 +05:30
Afsal Syed
1d8ce1ee8c fix(stock): allow zero completed quantity and handle process loss in job cards (#59104) 2026-09-16 20:14:32 +05:30
pandiyan
62d0e2c82e test: cover bank reconciliation date validation 2026-09-16 15:57:50 +05:30
pandiyan
970f32a342 fix: validate bank reconciliation date ranges 2026-09-16 15:57:43 +05:30
Shllokkk
4b858ec0af Merge pull request #59078 from Shllokkk/soa-render-letter-head-template
fix: render letter head jinja in statement of accounts pdf
2026-09-16 15:39:55 +05:30
rohitwaghchaure
f6b907ef9d fix: update modified for last purchase rate (#59044) 2026-09-16 15:23:47 +05:30
Pandiyan P
2273448f1b fix: avoid pricing rule help template errors (#59097) 2026-09-16 07:40:22 +00:00
Pandiyan P
b439bdda68 fix: clear stock value for zero-quantity balances (#58899)
* fix: clear stock value for zero-quantity balances

* test(stock): cover zero-quantity residual stock values
2026-09-16 13:00:27 +05:30
Khushi Rawat
4974372073 Merge pull request #59085 from khushi8112/print-format-classic-item-description
fix: show item description in Classic print formats
2026-09-16 12:18:20 +05:30
Pandiyan P
2ad4a8c4a4 fix(manufacturing): apply mrp manufacturing buffer as a duration (#59007)
* fix(manufacturing): apply mrp manufacturing buffer as a duration

* test(manufacturing): cover mrp buffer durations
2026-09-16 06:22:43 +00:00
Pandiyan P
00ee694444 fix(stock): filter item group defaults by company and non-group records (#58923) 2026-09-16 11:47:51 +05:30
Sudharsanan Ashok
6cee9c330c fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)
* fix(stock): use stored posting_datetime for repost boundary

get_stock_ledger_entries re-derived posting_datetime from posting_date and
posting_time on every call, discarding the stored value its callers pass in.
when a row's stored posting_datetime differs from that pair, the replay window
is built from the wrong instant: the row falls outside the range filter and is
never recomputed, while get_previous_sle still selects it as the opening
balance and reuses its stale qty_after_transaction. every later entry inherits
the error, leaving bin qty adrift from the sum of its ledger.

derive the boundary only when the caller has not supplied one.

* fix(stock): match current voucher sle on stored posting_datetime

get_sle_against_current_voucher selected rows with an equality check against a
posting_datetime re-derived from posting_date and posting_time. a row whose
stored posting_datetime differs from that pair matches nothing, so reposting
the voucher silently processes zero entries and the row can never be corrected
through its own voucher.

read the timestamp from the stored row when the sle is known, and derive it
only as a fallback.

* test(stock): cover repost with diverged posting_datetime

add a repack scenario whose incoming entry stores a posting_datetime one
microsecond before its own posting_time. asserts the voucher lookup still
finds that entry, and that reposting replays it instead of reusing its stale
qty_after_transaction, which otherwise left bin qty at 115 against 615 of
recorded movements.
2026-09-16 11:45:32 +05:30
khushi8112
f73d818ff4 feat: show item description in Classic print formats
Merge the item description as a secondary line under the item name in the
Item column of all Classic transaction print formats.
2026-09-16 11:36:15 +05:30
Raffael Meyer
29335f56b2 feat(manufacturing): ask for a BOM before creating MRP work orders (#58511)
* feat(manufacturing): ask for a BOM before creating MRP work orders

* test(manufacturing): cover MRP work orders created with a supplied BOM
2026-09-16 01:59:21 +02:00
Raffael Meyer
965124ba7c fix(manufacturing): classify MRP rows by purchase item, not missing BOM (#58510) 2026-09-16 01:43:50 +02:00
Khushi Rawat
2fff457e34 Merge pull request #58688 from krishna-254/fix/issue-55219-bank-reconciliation-document-types
fix: handle missing bank reconciliation document types
2026-09-16 00:01:58 +05:30
Khushi Rawat
8e14cd2394 Merge pull request #58941 from Henil666/fix/asset-depreciation-checkbox-order-clean
fix: asset depreciation table stays empty if you check the box before entering the amount
2026-09-15 23:57:28 +05:30
Khushi Rawat
514c3b45d0 chore: remove unnecessary comments 2026-09-15 23:56:20 +05:30
Shllokkk
da377683d6 fix: render letter head jinja in statement of accounts pdf 2026-09-15 21:52:30 +05:30
Mihir Kandoi
9be19e69b5 fix(buying): show linked record count on Supplier Scorecard connections (#59079) 2026-09-15 13:58:42 +00:00
Mihir Kandoi
41ad83dfb1 fix(ui): do not decorate self-referential links with the row's own title (#59023) 2026-09-15 19:03:03 +05:30
Mihir Kandoi
09dea8b1b5 fix: item selection writes to the child table that fired it (#59068) 2026-09-15 12:13:43 +05:30
Mihir Kandoi
f3dcc6180f fix(stock): value stock entry rows as of the posting date (#59042) 2026-09-15 12:11:21 +05:30
MochaMind
198d40446f fix: sync translations from crowdin (develop) (#59051)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-14 19:06:24 +02:00
Mihir Kandoi
5a63b36c3b fix(stock): consume batch slots newest first for LIFO items (#59062)
* fix(stock): consume batch slots newest first for LIFO items

The valuation method decided which end of the queue an issue consumed
from, but only for stock carrying no batch or serial number. Batch slots
were always consumed from the head, so a LIFO item reported its oldest
stock as still on hand when it had been issued.

Slots of one batch valued batchwise share a date, so the direction of
the walk cannot change what they report. Slots pooled across batches
carry the date of the batch that filled them, and there the wrong stock
aged.

Pass the valuation method through to the batch walk and read the queue
from the tail for a LIFO item, as the untagged walk already does.

* test(stock): cover LIFO consumption of pooled batch slots

Issue against the newer of two pooled batches on a LIFO item and assert
the September slot is consumed rather than the January one.
2026-09-14 17:51:15 +05:30
Mihir Kandoi
990a43ed16 fix(stock): scope stock ageing batch and serial age to the warehouse (#59058)
* fix(stock): scope stock ageing batch and serial age to the warehouse

The first inward posting date of a batch or serial number was cached
under the identity alone, so the age of a row depended on which stock
ledger entries the filters let the report scan.

A batch received into WH A and transferred to WH B aged from the WH A
receipt in an unfiltered run, but from the transfer date once a
warehouse filter was applied. Same stock, same warehouse, same to date,
two different ages.

Key the cache on the warehouse as well. Repeated receipts of one batch
into one warehouse still age from the first of them, and a transfer now
restarts the clock in the destination warehouse, as it already does for
stock that carries no batch or serial number.

* test(stock): cover warehouse scoped batch age in stock ageing

A batch received into one warehouse and transferred to another aged
from the first receipt in an unfiltered run and from the transfer once
the warehouse filter narrowed the scan. Assert both runs report the
transfer date.

* test(stock): cover warehouse scoped serial age in stock ageing

The cached date is keyed on the warehouse for serial numbers as well as
batches, and only the batch half was covered. Assert a serial
transferred between warehouses ages from the transfer in both a full
and a warehouse filtered scan.

Insert the batch fixture with ignore_if_duplicate instead of checking
for it first.
2026-09-14 17:51:14 +05:30
Mihir Kandoi
9d3675405a perf(stock): look up stock accounts once per company in the warehouse account map (#59056) 2026-09-14 13:15:12 +05:30
Sudharsanan Ashok
fe25746feb fix(subcontracting): validate project across the subcontracting flow (#58965) 2026-09-13 11:58:56 +05:30
Mihir Kandoi
f5f956c4dd test: stop six tests from passing without running (#59025)
* test: stop four tests from passing without running

Three advisory-lock tests return early on MariaDB:

    if frappe.db.db_type != "postgres":
        return

A bare return reports the test as passed, so the MariaDB CI job shows
green for a test it never ran. skipTest reports it as skipped.

test_stock_reco_with_opening_stock_with_diff_inventory returned early
when the custom "Plant" DocType already existed. DocType creation is
DDL and survives the test transaction, so the test ran once on a fresh
site and silently did nothing on every run after that. Create the
DocType only when it is missing and let the test run either way.

Its closing loop also asserted inside an if/elif over the ledger rows,
which verified nothing if the dimension came back unset. Compare the
whole {plant: qty} mapping instead.

* test: give the job card validator tests a real job card

Both tests looked for a submitted Job Card left behind by another test
and returned when they did not find one:

    jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
    if not jc_name:
        return  # skip if no job cards in test data

Run in isolation they asserted nothing and still reported a pass, and
they were the only coverage for validate_job_card_fg_item and
validate_job_card_item.

Move them to test_job_card.py, where the Work Order and BOM fixtures
that produce Job Cards already live, and build the Job Card in the test.
The finished-good case needs a card that carries one, so it goes through
a track_semi_finished_goods BOM. Both now assert on the message text, and
both fail if the validator body is removed.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
fe9d6e5a57 test: remove seven tests that cannot fail (#59024)
Four tests in test_stock_entry.py each cover only an early-return guard:

    def test_validate_job_card_item_skips_when_no_job_card(self):
        se = frappe.new_doc("Stock Entry")
        se.job_card = None
        se.validate_job_card_item()  # must not raise

That exercises `if not self.job_card: return` and nothing else. The
mismatch tests next to them already cover the behaviour these validators
actually implement.

Three tests in test_payment_request.py assert against their own mock.
_is_v2_gateway delegates to payments.utils.is_v2_gateway; all three mock
that delegate to return False and then assert the result is False, for
inputs (None, "", "NonExistentGateway12345") that take an identical code
path. The mock decides the outcome, so the assertion holds regardless of
what ERPNext does. The three tests covering the real branches --
delegation, a False delegate, and the exception fallback -- are kept.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
bec627c3eb refactor(manufacturing): rename secondary item valuation type to "% of Component Cost" (#59021) 2026-09-12 06:55:42 +05:30
Mihir Kandoi
bee358ea25 fix(support): stop the SLA link filter patches from aborting migrate on PostgreSQL (#59018) 2026-09-12 06:20:50 +05:30
Pandiyan P
000dcfc23d feat(stock): show reserved stock in batch balance history (#59008) 2026-09-11 18:21:16 +05:30
Pandiyan P
825d24f406 fix(stock): calculate batch bundle valuation per unit (#58994) 2026-09-11 12:50:49 +00:00
Pandiyan P
e6f431a8d6 fix: prevent disabled items from being used in BOM (#58997)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-11 12:20:30 +00:00
Mihir Kandoi
5dfd21cce6 fix(selling): list billable sales orders oldest first (#59010) 2026-09-11 12:05:02 +00:00
Krishna Pramod Shirsath
b85da1628d Merge branch 'develop' into fix/issue-55219-bank-reconciliation-document-types 2026-09-11 16:25:39 +05:30
Shllokkk
6f5f2cfce1 Merge pull request #58979 from Shllokkk/fix-bom-cost-allocation-per-type
fix: coerce cost_allocation_per to float in BOM cost allocation
2026-09-11 14:16:57 +05:30
Mihir Kandoi
142976a829 test(accounts): expect a naming rule counter per resolved prefix (#59000) 2026-09-11 08:12:52 +00:00
Henil
5a8126412a fix(assets): discard stale set_finance_book responses
Rapid successive edits to Net Purchase Amount could fire overlapping
set_finance_book calls; if an older request's response arrived after a
newer one, it could overwrite Finance Books with values computed from
a stale amount. Now the callback only applies a response if the fields
it was based on still match the form's current values.
2026-09-11 13:02:04 +05:30
Diptanil Saha
1dd0b7dc9e fix(regional)!: remove irs_1099_print whitelisted method (#58976) 2026-09-11 09:40:22 +05:30
Diptanil Saha
b481083ff0 fix(accounts): add permission checks on invoice_discounting.get_invoices (#58975) 2026-09-11 09:39:38 +05:30
Raffael Meyer
be8208e7cb fix(stock): recalculate billing status of returned delivery notes (#58953) 2026-09-10 19:56:28 +02:00
Mihir Kandoi
4671d1a665 fix(stock): apply company restriction to Item Price (#58948) 2026-09-10 20:51:48 +05:30
Mihir Kandoi
5f216c5d55 fix(selling): exclude fully billed orders from the invoice picker (#58966) 2026-09-10 20:50:11 +05:30
Shllokkk
4d95a240bb chore: resolve merge conflicts 2026-09-10 19:07:25 +05:30
Diptanil Saha
86821ac6ee fix(accounts): add permission checks on execute_repost_payment_ledger (#58980) 2026-09-10 13:33:38 +00:00
Shllokkk
bc2fa03730 fix: coerce cost_allocation_per to float in BOM cost allocation 2026-09-10 18:53:39 +05:30
Mihir Kandoi
e6b8e90ad9 fix(stock): do not rebuild the Warehouse tree on an account map miss (#58956) 2026-09-10 18:15:28 +05:30
Pandiyan P
d82c35aae9 fix: avoid TypeError on new Work Order when company is not set (#58969)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-10 12:33:06 +00:00
Sudharsanan Ashok
b1e99a70ac fix(mrp): align monthly bucket labels with demand dates (#58963) 2026-09-10 12:13:44 +00:00
Mihir Kandoi
d5e63b8a9e fix(subcontracting): cannot select Against Finished Good on customer receipt (#58949) 2026-09-09 18:42:31 +05:30
Pandiyan P
3be0c7801a fix(manufacturing): handle empty raw materials in workstation (#58927) 2026-09-09 18:05:03 +05:30
Pandiyan P
33a066d568 fix(manufacturing): handle empty bom cost allocation (#58939) 2026-09-09 18:04:10 +05:30
ruthra kumar
cf6aeddc29 Merge pull request #58732 from ruthra-kumar/rewriting_process_pcv_on_map_reduce
refactor: rewriting process pcv on mapreduce
2026-09-09 17:28:04 +05:30
ruthra kumar
3f422f8e0d refactor: use qb for estimation and include correction factor 2026-09-09 17:08:40 +05:30
Afsal Syed
f130c64530 fix(stock): set item_name to fetch if empty in packing slip item (#58925) 2026-09-09 11:36:56 +00:00
Henil
5d1ffa7fca fix(assets): build Finance Books even when Calculate Depreciation is checked before Net Purchase Amount is entered
Previously, checking "Calculate Depreciation" (or picking the Item)
before typing in "Net Purchase Amount" left the Finance Books table
empty, because the depreciation schedule was only built at the moment
those fields already had values. Entering the amount afterward only
updated existing Finance Books rows, so an empty table stayed empty.

Now, entering the amount also builds Finance Books from scratch if it
was left empty, regardless of the order fields were filled in.
2026-09-09 16:34:36 +05:30
ruthra kumar
681bd2734f refactor: post ledger entries once mapreduce is complete 2026-09-09 16:23:32 +05:30
ruthra kumar
31205c4114 refactor: dashboard for pcv 2026-09-09 16:23:32 +05:30
ruthra kumar
503a80f2c9 refactor: dynamic link to mapreduce and clean on cancel and trash 2026-09-09 16:23:32 +05:30
ruthra kumar
3a8bd852d5 refactor: rebuild pcv on mapreduce (parallelization) 2026-09-09 16:23:31 +05:30
Diptanil Saha
a8ec43bcf8 fix(selling): add email permission check on send_emails (#58935) 2026-09-09 15:37:55 +05:30
Diptanil Saha
e2b2940452 fix(accounts): add permission checks on send_proforma_email (#58933) 2026-09-09 09:53:29 +00:00
Shllokkk
3761eb8cbe fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (#58885)
* fix: include rejected qty in Purchase Receipt billing base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: per billed stays 100% for fully rejected receipt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:58:18 +05:30
Mihir Kandoi
fd492100b0 fix(stock): ignore disabled price lists on transactions (#58926) 2026-09-09 09:15:44 +00:00
Mihir Kandoi
28bd1332de fix(accounts): stop counting received qty on non-stock invoice returns (#58924)
* fix(accounts): skip received/rejected qty checks on non-stock return

A Purchase Invoice without Update Stock writes no Stock Ledger Entry, so
received_qty and rejected_qty on its rows move no stock and bill no
amount. The server never derives or validates them either:
validate_accepted_rejected_qty only runs when update_stock is set.

validate_quantity still counted both columns against the source invoice.
Whatever value the form last wrote to the read-only received_qty was
tallied as returned, so a partial return that lowered qty locked out the
rest of the invoice with StockOverReturnError.

Restrict the two columns to documents that actually carry an
accepted/rejected split: Purchase Receipt, Subcontracting Receipt, and a
Purchase Invoice with Update Stock. qty stays validated in every case, so
the billed quantity is still capped at what the source invoice billed.

* test(accounts): cover partial returns of a non-stock invoice

Fails before the previous commit with StockOverReturnError on the second
return, because the stale received_qty carried by the first return is
tallied as a full return of the invoice.

* refactor(accounts): pass frm into the Purchase Invoice hide_fields

hide_fields took a doc but reached for cur_frm to get the grid and to
refresh, so it only worked on whichever form happened to be current. Take
frm instead: all three callers already have one.

frm.toggle_display replaces the hide_field / unhide_field globals, which
resolve the docfield through cur_frm the same way. var becomes let/const.

No change in behaviour.

* fix(accounts): hide stock columns without Update Stock

Received Qty, Rejected Qty and the warehouse section were shown on any
return, including one that updates no stock. received_qty is read-only
there and rejected_qty moves neither stock nor billed amount, so the grid
offered values the user could not correct and the form could not keep in
step with qty.

Show the group only when the invoice updates stock, matching an ordinary
Purchase Invoice. Nothing on these rows needs a warehouse either:
validate_warehouse only checks the warehouses that are set.
2026-09-09 08:55:35 +00:00
Pandiyan P
4b23cee2ea fix(stock): remove empty delivery stops after mapping (#58896)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:24:10 +00:00
Khushi Rawat
bdf124ab06 Merge pull request #58909 from khushi8112/fix/posting-date-confirmation
fix: read posting date confirmation setting from boot data
2026-09-09 12:50:41 +05:30
Pandiyan P
b2bdeaa672 fix: validate shelf life for automatically created expiring batches (#58911)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-09 07:07:03 +00:00
khushi8112
bcade8f0ba fix: refresh posting date confirmation setting 2026-09-09 12:36:28 +05:30
Pandiyan P
4c32acf300 fix: reset price list fallback when changing parties (#58893) 2026-09-09 12:26:05 +05:30
khushi8112
db9e93306a fix: read posting date confirmation setting from boot data 2026-09-09 11:52:42 +05:30
Khushi Rawat
e93ca84398 Merge pull request #58227 from aerele/fix/dunning-interest-lost-after-payment
fix: keep a dunning claimable until its interest is paid too
2026-09-09 10:47:07 +05:30
Pandiyan P
afd93cf867 fix(stock): guard inventory dimension lookup without reference document (#58889)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:42:40 +05:30
Vishnu Priya Baskaran
51fb261b6b fix: calculate holiday list totals for half days (#58792)
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:38:23 +05:30
Pandiyan P
f8c2f3440b fix(accounts): reject disabled price lists in buying and selling (#58891) 2026-09-09 06:57:48 +05:30
Diptanil Saha
991cea5ae2 fix(accounts): add permission checks to Chart of Accounts Importer methods (#58892) 2026-09-09 01:14:27 +05:30
Diptanil Saha
11d2847d3e fix(support): bind issue split to its own timeline (#58890) 2026-09-09 00:26:39 +05:30
Krishna Pramod Shirsath
e0d6d797d7 fix(stock): preserve rates for unsaved mapped rows (#58852)
* fix(stock): preserve rates for unsaved mapped rows

* test(stock): cover mapped delivery note item rates
2026-09-08 18:45:02 +05:30
pandiyan
d5a9d158f9 fix: keep a dunning claimable until its interest is paid too
a dunning was resolved as soon as the invoiced sum was settled, because the
status was derived from the invoice outstanding alone. paying an invoice
without the interest and fee therefore closed the dunning and lost the
interest: a fresh dunning finds nothing overdue to charge it on.

the dunning amount is never a receivable, it only reaches the ledger as a
negative deduction on a payment entry made from the dunning. link that row
to the dunning so what has been collected is known, and resolve a dunning
only once the invoiced sum and the dunning amount are both paid. a dunning
resolved by hand keeps its status, so waiving the interest stays possible.

the deduction is a company currency field, so book and measure the dunning
amount through base_dunning_amount instead of the transaction currency one.

an interest-only payment leaves every invoice outstanding untouched, so
update the linked dunnings from the payment entry itself instead of relying
on the outstanding amount to change. such a payment also has to be built
from what is left to collect, not from the totals the dunning was raised
with, which are stale by then.
2026-09-08 17:49:36 +05:30
pandiyan
fa89552d10 test: dunning must stay unpaid when only the invoiced sum is paid
paying a dunned invoice without its interest and fee marks the dunning
resolved, and the interest can no longer be collected.
2026-09-08 17:48:15 +05:30
Krishna Pramod Shirsath
cee9f4949a fix: show cost center for unmatched payments in receivables (#58453) 2026-09-08 16:52:53 +05:30
Vishnu Priya Baskaran
218e7927ff fix: fetch accounts by company (#58523) 2026-09-08 16:47:46 +05:30
Pandiyan P
36a4dfe797 fix(accounts): reject same-account internal transfers (#58529) 2026-09-08 16:29:20 +05:30
Sudharsanan Ashok
e825bb2f74 fix(accounts): round journal entry totals to field precision (#58629) 2026-09-08 16:26:04 +05:30
Pandiyan P
f864333afa fix(stock): recalculate delivery note billing after return (#58869) 2026-09-08 10:51:55 +00:00
Krishna Pramod Shirsath
c412310eb5 fix: use account currency in Bank Reconciliation Statement print (#58692) 2026-09-08 16:14:09 +05:30
Sudharsanan Ashok
1728d1b0f5 fix(stock): distribute additional costs when incoming items have no value (#58842)
* fix(stock): distribute additional costs when incoming items have no value

when every incoming row has zero basic amount, for example a raw material
purchased at zero rate, distribute_additional_costs returned early and left
additional_cost at 0 on the finished item. the charges were never capitalised
into valuation and the balancing debit stayed in stock adjustment instead of
reaching stock in hand.

the gl composer had its own quantity fallback for the same case, but it divided
by the qty of every row rather than the incoming ones, so a manufacture entry
booked only a fraction of the cost to the expense account, and it apportioned
by qty while valuation apportions by stock qty, which split rows of differing
conversion factors two different ways.

both sides now take the rows, the basis and its total from a single
get_additional_cost_allocation, falling back to transfer_qty so valuation and gl
cannot disagree. the basis is unchanged whenever the incoming rows carry value.

* test(stock): cover additional cost distribution for zero valued items

adds qty based distribution cases for manufacture and material receipt, a
manufacture entry asserting the whole cost reaches its expense account, and a
conversion factor case asserting the gl split matches the valuation split.

updates test_total_basic_amount_zero, which asserted the cost landing in stock
adjustment rather than being capitalised.
2026-09-08 16:13:12 +05:30
Lakshit Jain
79fc039092 Merge pull request #58682 from ljain112/fix-tds-subcontracting
fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping
2026-09-08 14:53:58 +05:30
Vishnu Priya Baskaran
467f54162f fix(gross-profit): include item_name in export (#58631) 2026-09-08 14:43:42 +05:30
Diptanil Saha
f60c349794 fix(buying): restrict linked material requests to permitted documents (#58855) 2026-09-08 14:23:03 +05:30
Pandiyan P
cadc0ca86d fix: persist rendered terms on transaction save (#58858) 2026-09-08 13:28:30 +05:30
Sudharsanan Ashok
2f572b1624 fix(stock): distribute additional costs when incoming items have no value (#58841) 2026-09-08 13:27:41 +05:30
ljain112
13031d6d5d fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping 2026-09-02 13:25:23 +05:30
Krishna Shirsath
d39e677d80 fix: handle missing bank reconciliation document types 2026-09-02 13:21:24 +05:30
262 changed files with 7280 additions and 1470 deletions

View File

@@ -180,6 +180,13 @@ audit of these fixes found four recurring mistakes:
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
MariaDB on case. Fix: take a representative row rather than sorting text.
**Picking that row is the hard part.** `Min(name)` is still a text sort: `autoname="hash"` is
not reliably lower case, because `_get_timestamp_prefix()` prepends `get_trace_id()[-1:]`
un-lowered and a client-supplied `X-Frappe-Request-Id` can put an upper case `A-F` there. A
non-text key (`Min(idx)`) works only where it is **unique within the group** and the join-back
carries the **full group key** — a date is usually neither, and joining on a duplicated value
turns one group into several rows (§3). Otherwise select the row in Python, sorting with
`key=str.casefold` so the order matches MariaDB's collation without depending on the database's.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.

View File

@@ -55,6 +55,18 @@ class ERPNextAddress(Address):
@frappe.whitelist()
def get_shipping_address(company: str, address: str | None = None):
# `company` is caller supplied and this returns that company's own registered address with every
# field. `select` rather than `read` on Company: Delivery, Maintenance, Purchase Manager and
# Stock Manager all fill in transactions that ask for this while holding no Company `read` row.
frappe.has_permission("Company", ptype="select", throw=True)
# and scope it to the caller's own Company restrictions, which costs nobody who has none
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
filters = [
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],

View File

@@ -24,7 +24,7 @@ def get(
heatmap_year: str | None = None,
):
if chart_name:
chart = frappe.get_doc("Dashboard Chart", chart_name)
chart = frappe.get_doc("Dashboard Chart", chart_name, check_permission="read")
else:
chart = frappe._dict(frappe.parse_json(chart))
timespan = chart.timespan
@@ -46,6 +46,9 @@ def get(
if not account:
frappe.throw(_("Account filter not set!"))
# authorise the account itself, as get_balance_on() does; doc= brings User Permissions with it
frappe.has_permission("Account", doc=account, throw=True)
if not to_date:
to_date = nowdate()
if not from_date:

View File

@@ -503,24 +503,21 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
Account = frappe.qb.DocType("Account")
search_field_obj = getattr(Account, searchfield)
query = (
frappe.qb.from_(Account)
.select(Account.name)
.where(Account.is_group == 1)
.where(Account.docstatus != 2)
.where(Account.company == filters["company"])
.where(search_field_obj.like(f"%{txt}%"))
.order_by(Account.name)
.limit(page_len)
.offset(start)
return frappe.get_list(
"Account",
filters=[
["is_group", "=", 1],
["docstatus", "!=", 2],
["company", "=", filters["company"]],
[searchfield, "like", f"%{txt}%"],
],
fields=["name"],
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
return query.run(as_list=1)
def get_account_currency(account):
"""Helper function to get account currency"""

View File

@@ -223,8 +223,11 @@ def delete_accounting_dimension(doc):
frappe.clear_cache(doctype=doctype)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def disable_dimension(doc: str):
# toggle_disabling rewrites a Custom Field site-wide, so demand the write that configures dimensions
frappe.has_permission("Accounting Dimension", "write", throw=True)
if frappe.in_test:
toggle_disabling(doc=doc)
else:

View File

@@ -222,6 +222,13 @@ class AccountsSettings(Document):
set_allow_on_submit_for_dimension_fields(doctypes)
@frappe.whitelist(methods=["POST"])
def get_posting_date_confirmation() -> int:
return cint(
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
)
def toggle_accounting_dimension_sections(hide):
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
for doctype in accounting_dimension_doctypes:

View File

@@ -1,9 +1,15 @@
import frappe
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsSettings(ERPNextTestSuite):
def test_posting_date_confirmation_uses_current_setting(self):
for enabled in (0, 1, 0):
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
self.assertEqual(get_posting_date_confirmation(), enabled)
def test_stale_days(self):
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
cur_settings.allow_stale = 0

View File

@@ -60,6 +60,9 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
if not isinstance(reference_name, str):
raise TypeError("reference_name must be a string")
# the form is the boundary, not the referenced order: an order guard would break one of the two roles
frappe.has_permission("Bank Guarantee", throw=True)
fields_to_fetch = ["grand_total"]
if bank_guarantee_type == "Receiving":
@@ -70,4 +73,14 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
doctype = "Purchase Order"
fields_to_fetch.append("supplier")
# and scope the referenced order to the caller's own Company restrictions, which costs nobody
# who has none
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Bank Guarantee")
if allowed_companies:
company = frappe.db.get_value(doctype, reference_name, "company")
if company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)

View File

@@ -68,6 +68,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
frappe.msgprint(__("Please select Bank Account"));
return;
}
frm.events.validate_dates(frm);
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
args: {
@@ -82,7 +83,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
});
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
frm.trigger("make_reconciliation_tool");
return frm.trigger("make_reconciliation_tool");
});
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
@@ -106,7 +107,24 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
frm.trigger("get_account_opening_balance");
},
validate_dates(frm) {
const from_date = frm.doc.filter_by_reference_date
? frm.doc.from_reference_date
: frm.doc.bank_statement_from_date;
const to_date = frm.doc.filter_by_reference_date
? frm.doc.to_reference_date
: frm.doc.bank_statement_to_date;
if (from_date && to_date && from_date > to_date) {
frappe.throw(
frm.doc.filter_by_reference_date
? __("From Reference Date cannot be greater than To Reference Date")
: __("From Date cannot be greater than To Date")
);
}
},
make_reconciliation_tool(frm) {
frm.events.validate_dates(frm);
frm.get_field("reconciliation_tool_cards").$wrapper.empty();
if (frm.doc.company && frm.doc.bank_account && frm.doc.bank_statement_to_date) {
frm.trigger("get_cleared_balance").then(() => {

View File

@@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Max, Sum
from frappe.utils import cint, create_batch, flt
from frappe.utils import cint, create_batch, flt, getdate
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
@@ -54,6 +54,8 @@ def get_bank_transactions(
all_transactions: bool = False,
):
# returns bank transactions for a bank account
validate_date_range(from_date, to_date)
filters = []
filters.append(["bank_account", "=", bank_account])
filters.append(["docstatus", "=", 1])
@@ -962,9 +964,10 @@ def auto_reconcile_vouchers(
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: bool | None = None,
to_reference_date: str | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
bank_transactions = get_bank_transactions(bank_account)
if len(bank_transactions) > 10:
@@ -1079,10 +1082,11 @@ def get_linked_payments(
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: bool | None = None,
to_reference_date: str | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
# get all matching payments for a bank transaction
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_account = frappe.db.get_values(
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
@@ -1102,6 +1106,23 @@ def get_linked_payments(
return subtract_allocations(gl_account, matching)
def validate_date_range(
from_date,
to_date,
filter_by_reference_date=False,
from_reference_date=None,
to_reference_date=None,
):
if cint(filter_by_reference_date):
from_date, to_date = from_reference_date, to_reference_date
message = _("From Reference Date cannot be greater than To Reference Date")
else:
message = _("From Date cannot be greater than To Date")
if from_date and to_date and getdate(from_date) > getdate(to_date):
frappe.throw(message)
def subtract_allocations(gl_account, vouchers):
"Look up & subtract any existing Bank Transaction allocations"
copied = []
@@ -1138,6 +1159,7 @@ def check_matching(
from_reference_date=None,
to_reference_date=None,
):
document_types = document_types or []
exact_match = True if "exact_match" in document_types else False
common_filters = frappe._dict(

View File

@@ -131,6 +131,37 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_get_linked_payments_without_document_types(self):
bank_transaction = self.make_bank_transaction(date=today())
self.assertEqual(get_linked_payments(bank_transaction.name), [])
def test_rejects_reversed_date_ranges(self):
from_date, to_date = today(), add_days(today(), -1)
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
get_bank_transactions(self.bank_account, from_date, to_date)
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
auto_reconcile_vouchers(
self.bank_account,
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)
transaction = self.make_bank_transaction(date=today())
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
get_linked_payments(
transaction.name,
["payment_entry"],
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)
def test_deposit_matches_amount_received_in_bank_account(self):
# money leaves another bank account and lands here minus a charge, so the two sides differ
payment = frappe.get_doc(

View File

@@ -437,6 +437,11 @@ def get_import_logs(docname: str):
@frappe.whitelist()
def upload_bank_statement(**args):
# The only caller is the Bank Reconciliation Tool's "Upload Bank Statement" button, whose
# callback routes straight into a new Bank Statement Import form — so `create` is exactly the
# right to require, and both doctypes are System Manager only, which makes it loser-free.
frappe.has_permission("Bank Statement Import", "create", throw=True)
args = frappe._dict(args)
bsi = frappe.new_doc("Bank Statement Import")

View File

@@ -11,6 +11,11 @@ from frappe.utils.dateutils import parse_date
@frappe.whitelist()
def upload_bank_statement():
# Parsing a statement is the first step of creating Bank Transactions from it, so that is the
# right to require. Both functions in this file are reached only over HTTP — nothing in the tree
# calls either — so there is no caller to break.
frappe.has_permission("Bank Transaction", "create", throw=True)
if getattr(frappe, "uploaded_file", None):
with open(frappe.uploaded_file, "rb") as upfile:
fcontent = upfile.read()
@@ -36,6 +41,12 @@ def upload_bank_statement():
@frappe.whitelist(methods=["POST"])
def create_bank_entries(columns: str, data: str | list, bank_account: str):
# insert()/submit() below already enforce this per document, but only after the per-row loop has
# read the Bank Account and its Bank mapping and written an Error Log for every rejected row —
# so check once up front rather than failing row by row.
frappe.has_permission("Bank Transaction", "create", throw=True)
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
header_map = get_header_mapping(columns, bank_account)
success = 0

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -110,7 +113,10 @@ def import_coa(file_name: str, company: str):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -179,6 +185,8 @@ def get_coa(
):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type: str, template_type: str, company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
return writer
@frappe.whitelist()
def validate_accounts(file_doc: Document, extension: str):
if extension == "csv":
accounts = generate_data_from_csv(file_doc, as_dict=True)

View File

@@ -17,7 +17,8 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import getdate
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext.controllers.accounts_controller import AccountsController
@@ -147,6 +148,31 @@ class Dunning(AccountsController):
)
row.dunning_level = len(past_dunnings) + 1
def get_unpaid_base_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in company currency."""
if not self.base_dunning_amount:
return 0.0
return flt(
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
self.precision("base_dunning_amount"),
)
def get_unpaid_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
return flt(
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
self.precision("dunning_amount"),
)
def get_unpaid_overdue_payments(self):
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
return [
(row, outstanding)
for row in self.overdue_payments
if (outstanding := get_current_outstanding(row)) > 0
]
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = [
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
if has_outstanding:
break
new_status = "Resolved" if not has_outstanding else "Unresolved"
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
if dunning.status != new_status:
dunning.status = new_status
dunning.save()
def update_dunnings_linked_to_payment(payment_entry):
"""Refresh dunnings whose interest and fee are settled by this payment."""
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
for name in dunnings:
dunning = frappe.get_doc("Dunning", name)
if dunning.docstatus != 1:
continue
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
# resolving by hand waives the interest, only an invoice that is owed again reopens it
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
return
if dunning.status != new_status:
dunning.db_set("status", new_status, notify=True)
def get_paid_dunning_amount(dunning: str) -> float:
"""Interest and fee collected for this dunning, in company currency."""
deduction = frappe.qb.DocType("Payment Entry Deduction")
payment_entry = frappe.qb.DocType("Payment Entry")
paid = (
frappe.qb.from_(deduction)
.join(payment_entry)
.on(payment_entry.name == deduction.parent)
.select(Sum(deduction.amount))
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
).run()
# the dunning amount is booked as a negative deduction, against the income account
return -flt(paid[0][0]) if paid else 0.0
def get_current_outstanding(overdue_payment) -> float:
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
invoice = frappe.db.get_value(
"Sales Invoice",
overdue_payment.sales_invoice,
["outstanding_amount", "currency", "party_account_currency"],
as_dict=True,
)
schedule_outstanding = (
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
if overdue_payment.payment_schedule
else flt(overdue_payment.outstanding)
)
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
return 0.0
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
if invoice.currency == invoice.party_account_currency:
outstanding = min(outstanding, flt(invoice.outstanding_amount))
return outstanding
def get_linked_dunnings_as_per_state(sales_invoice, state):

View File

@@ -55,6 +55,125 @@ class TestDunning(ERPNextTestSuite):
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
"""
Regression for #58220: paying the invoice without the interest and fee must not
resolve the dunning, the interest is still owed and has to stay claimable.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "4", nowdate()
pe.insert()
pe.submit()
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the interest and fee can still be collected on their own
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "5", nowdate()
self.assertEqual(pe.references, [])
self.assertEqual(round(pe.paid_amount, 2), 10.41)
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
# cancelling the interest payment makes the dunning claimable again
pe.cancel()
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
"""
The payment collecting the interest links back to the dunning, which must not stand in
the way of cancelling it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "6", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
dunning.cancel()
self.assertEqual(dunning.docstatus, 2)
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
"""
Resolving a dunning by hand waives its interest, so a later payment of the invoice
must not reopen it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
# what the "Resolve" button does
dunning.reload()
dunning.status = "Resolved"
dunning.save()
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "7", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
"""
The interest and fee are collected as a Payment Entry deduction, a company currency
field, so what is left to collect has to be measured in the same currency.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -15),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _TC",
)
dunning = create_dunning_from_sales_invoice(si.name)
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
dunning.dunning_type = dunning_type.name
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.income_account = dunning_type.income_account
dunning.cost_center = dunning_type.cost_center
dunning.save()
self.assertEqual(dunning.currency, "USD")
self.assertEqual(dunning.conversion_rate, 50)
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
# nothing collected yet, in either currency
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the deduction booking the interest is in company currency
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
def test_fetch_overdue_payments(self):
"""
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.

View File

@@ -3,7 +3,6 @@
import ast
import json
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import cache, reduce
@@ -29,6 +28,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat
FinancialReportTemplate,
)
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FORMULA_FUNCTIONS,
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
@@ -1328,26 +1328,14 @@ class FormulaCalculator:
self.precision = get_currency_precision()
self.validator = CalculationFormulaValidator(set(row_data.keys()))
self.math_functions = {
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": math.sqrt,
"pow": math.pow,
"ceil": math.ceil,
"floor": math.floor,
}
def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]:
validation_result = self.validator.validate(report_row)
formula = report_row.calculation_formula
formula = (report_row.calculation_formula or "").strip()
negation_factor = -1 if report_row.reverse_sign else 1
if validation_result.issues:
# TODO: Throw?
messages = "<br><br>".join(issue.message for issue in validation_result.issues)
messages = "<br><br>".join(str(issue) for issue in validation_result.issues)
frappe.log_error(f"Formula validation errors found:\n{messages}")
return [0.0] * len(self.period_list)
@@ -1362,7 +1350,7 @@ class FormulaCalculator:
# TODO: consistent error handling
try:
context = self._build_context(period_index)
result = frappe.safe_eval(formula, context)
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
return flt(result * negation_factor, self.precision)
except ZeroDivisionError:
@@ -1383,7 +1371,7 @@ class FormulaCalculator:
context[code] = 0.0
# math functions
context.update(self.math_functions)
context.update(FORMULA_FUNCTIONS)
return context

View File

@@ -34,6 +34,13 @@ class FinancialReportTemplate(Document):
def before_validate(self):
self.clear_hidden_fields()
for row in self.rows:
if row.reference_code:
row.reference_code = row.reference_code.strip()
if row.calculation_formula:
row.calculation_formula = row.calculation_formula.strip()
def clear_hidden_fields(self):
style_data_sources = {"Blank Line", "Column Break", "Section Break"}

View File

@@ -2,6 +2,8 @@
# For license information, please see license.txt
import json
import keyword
import math
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -10,6 +12,19 @@ from typing import Any
import frappe
from frappe import _, is_whitelisted
from frappe.database.operator_map import OPERATOR_MAP
from frappe.utils import escape_html
FORMULA_FUNCTIONS = {
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": math.sqrt,
"pow": math.pow,
"ceil": math.ceil,
"floor": math.floor,
}
def get_valid_api_method(api_path: str):
@@ -89,8 +104,9 @@ class ValidationResult:
self.warnings.append(issue)
def notify_user(self) -> None:
warnings = "<br><br>".join(str(w) for w in self.warnings if w)
errors = "<br><br>".join(str(e) for e in self.issues if e)
# messages quote user input back, and both are rendered as HTML
warnings = "<br><br>".join(escape_html(str(w)) for w in self.warnings if w)
errors = "<br><br>".join(escape_html(str(e)) for e in self.issues if e)
if warnings:
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
@@ -147,18 +163,27 @@ class TemplateStructureValidator(Validator):
if not row.reference_code:
continue
ref_code = row.reference_code.strip()
ref_code = row.reference_code
# Check format
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
# a line reference is used as a name in formulas, so it must be a usable one
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code):
result.add_error(
ValidationIssue(
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
"Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores"
).format(ref_code),
row_idx=row.idx,
)
)
elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS:
result.add_error(
ValidationIssue(
message=_("'{0}' is a reserved name and cannot be used as a line reference").format(
ref_code
),
row_idx=row.idx,
)
)
# Check uniqueness
if ref_code in used_codes:
@@ -208,12 +233,7 @@ class DependencyValidator(Validator):
self.dependencies = self._build_dependency_graph()
def validate(self, context=None) -> ValidationResult:
result = ValidationResult()
result.merge(self._validate_circular_dependencies())
result.merge(self._validate_missing_dependencies())
return result
return self._validate_circular_dependencies()
def _build_dependency_graph(self) -> dict[str, list[str]]:
graph = {}
@@ -280,31 +300,6 @@ class DependencyValidator(Validator):
return result
def _validate_missing_dependencies(self) -> ValidationResult:
available = {row.reference_code for row in self.template.rows if row.reference_code}
result = ValidationResult()
for ref_code, deps in self.dependencies.items():
undefined = [d for d in deps if d not in available]
if undefined:
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
row_idx=row_idx,
)
)
return result
def _get_row_idx(self, reference_code: str) -> int | None:
for row in self.template.rows:
if row.reference_code == reference_code:
return row.idx
return None
class CalculationFormulaValidator(Validator):
"""Validates calculation formulas used in Calculated Amount rows"""
@@ -320,7 +315,6 @@ class CalculationFormulaValidator(Validator):
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
# Check parentheses
if not self._are_parentheses_balanced(formula):
@@ -368,25 +362,15 @@ class CalculationFormulaValidator(Validator):
def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None:
try:
context = {code: 1.0 for code in available_codes}
context.update(
{
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": lambda x: x**0.5,
"pow": pow,
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
"floor": int,
}
)
context.update(FORMULA_FUNCTIONS)
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
if not isinstance(result, (int | float)):
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return None
except ZeroDivisionError:
return None
except Exception as e:
return str(e)
@@ -462,13 +446,14 @@ class AccountFilterValidator(Validator):
return _("Field and operator must be strings")
if field not in account_fields:
# escape: `field` is caller-supplied and this message renders as HTML
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
return _("Field '{0}' is not a valid Account field").format(field)
if operator.casefold() not in OPERATOR_MAP:
normalized_operator = operator.casefold()
if normalized_operator not in OPERATOR_MAP:
return _("Invalid operator '{0}'").format(operator)
if operator in ["in", "not in"] and not isinstance(value, list):
if normalized_operator in ["in", "not in"] and not isinstance(value, list):
return _("Operator '{0}' requires a list value").format(operator)
# logical condition: {"and": [condition1, condition2]}

View File

@@ -5,8 +5,11 @@ import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FORMULA_FUNCTIONS,
AccountFilterValidator,
CalculationFormulaValidator,
FormulaValidator,
TemplateStructureValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -189,8 +192,13 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
def test_error_message_labels_and_escapes_field(self):
validator = AccountFilterValidator()
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
message = str(result.issues[0])
self.assertIn("[Account Filter]", message)
self.assertIn("[Account Filter]", str(result.issues[0]))
# escaping happens where the message is rendered, not where it is built
frappe.clear_messages()
with self.assertRaises(frappe.ValidationError):
result.notify_user()
message = frappe.get_message_log()[-1]["message"]
self.assertIn("&lt;script&gt;", message)
self.assertNotIn("<script>", message)
@@ -248,3 +256,149 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
pluck="name",
)
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
class TestFormulaEnvironment(FinancialReportTemplateTestCase):
"""Validator and engine must evaluate a formula in the same environment."""
@staticmethod
def _calc(row_data):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FormulaCalculator,
)
return FormulaCalculator(row_data, [{"key": "p1"}])
@staticmethod
def _row(formula):
return frappe._dict(
calculation_formula=formula,
idx=1,
reverse_sign=0,
data_source="Calculated Amount",
reference_code="X",
)
def test_engine_keeps_reference_codes_named_like_builtins(self):
# "int" and "long" are whitelisted safe_eval globals; the row values must win
calc = self._calc({"int": [500.0], "long": [2000.0]})
self.assertEqual(calc.evaluate_formula(self._row("int + long"))[0], 2500.0)
def test_validator_keeps_reference_codes_named_like_builtins(self):
validator = CalculationFormulaValidator({"int", "long"})
self.assertTrue(validator.validate(self._row("int + long")).is_valid)
def test_engine_uses_the_shared_function_list(self):
context = self._calc({"A": [1.0]})._build_context(0)
for name, function in FORMULA_FUNCTIONS.items():
self.assertIs(context[name], function)
def test_rounding_matches_math_module(self):
calc = self._calc({"A": [1.0]})
self.assertEqual(calc.evaluate_formula(self._row("floor(-2.5)"))[0], -3.0)
self.assertEqual(calc.evaluate_formula(self._row("ceil(-2.5)"))[0], -2.0)
class TestCalculationFormula(FinancialReportTemplateTestCase):
"""Formulas are test-evaluated with dummy values before a template can be saved."""
@staticmethod
def _validate(formula, codes=("A", "B", "C")):
row = frappe._dict(
calculation_formula=formula, idx=1, data_source="Calculated Amount", reference_code="X"
)
return CalculationFormulaValidator(set(codes)).validate(row)
def test_division_by_zero_is_not_a_validation_error(self):
# the dummy values are all 1.0, so a denominator can only be zero by accident;
# the engine tolerates real division by zero at run time
self.assertTrue(self._validate("A / (B - C)").is_valid)
self.assertTrue(self._validate("(A - B) / (A - C)").is_valid)
self.assertTrue(self._validate("ROM / (CAS + FDE - ROM)", ("ROM", "CAS", "FDE")).is_valid)
self.assertTrue(self._validate("A / 0").is_valid)
def test_broken_formulas_are_rejected(self):
self.assertFalse(self._validate("A +").is_valid)
self.assertFalse(self._validate("NOPE * 2").is_valid)
self.assertFalse(self._validate("'text'").is_valid)
class TestFilterOperatorCase(FinancialReportTemplateTestCase):
"""Operators are matched case-insensitively, so their value checks must be too."""
@staticmethod
def _row(formula):
return frappe._dict(calculation_formula=formula, idx=1)
def test_uppercase_in_requires_a_list_value(self):
validator = AccountFilterValidator()
self.assertFalse(validator.validate_filter(self._row('["root_type", "IN", "Income"]')).is_valid)
self.assertFalse(validator.validate_filter(self._row('["root_type", "NOT IN", "Income"]')).is_valid)
def test_uppercase_in_accepts_a_list_value(self):
validator = AccountFilterValidator()
self.assertTrue(validator.validate_filter(self._row('["root_type", "IN", ["Income"]]')).is_valid)
class TestLineReferenceNames(FinancialReportTemplateTestCase):
"""A line reference becomes a name in formulas, so it must be usable as one."""
@staticmethod
def _validate(code):
template = frappe._dict(rows=[frappe._dict(reference_code=code, idx=1, data_source="Blank Line")])
return TemplateStructureValidator()._validate_reference_codes(template)
def test_plain_codes_are_accepted(self):
for code in ("REV", "CA100", "cash_flow_2"):
self.assertTrue(self._validate(code).is_valid, code)
def test_hyphen_is_rejected(self):
# "-" reads as subtraction in a formula and is not a valid Python name
self.assertFalse(self._validate("REV-COGS").is_valid)
def test_python_keyword_is_rejected(self):
for code in ("if", "None", "class"):
self.assertFalse(self._validate(code).is_valid, code)
def test_formula_function_name_is_rejected(self):
# these would be overwritten by the function of the same name
for code in ("sum", "round", "abs"):
self.assertFalse(self._validate(code).is_valid, code)
def test_surrounding_spaces_are_normalised_before_validation(self):
template = frappe.new_doc("Financial Report Template")
template.template_name = "Spaces"
template.append("rows", {"reference_code": " REV ", "data_source": "Blank Line"})
template.append(
"rows",
{
"reference_code": "X",
"data_source": "Calculated Amount",
"calculation_formula": " REV * 2 ",
},
)
template.before_validate()
self.assertEqual(template.rows[0].reference_code, "REV")
self.assertEqual(template.rows[1].calculation_formula, "REV * 2")
def test_validation_does_not_modify_the_row(self):
row = frappe._dict(
calculation_formula=" REV * 2 ",
idx=1,
data_source="Calculated Amount",
reference_code="X",
)
CalculationFormulaValidator({"REV", "X"}).validate(row)
self.assertEqual(row.calculation_formula, " REV * 2 ")
def test_invalid_reference_code_is_escaped(self):
# this message fires when the code fails the format check, so it can hold anything
template = frappe._dict(rows=[frappe._dict(reference_code="<img src=x onerror=alert(1)>", idx=1)])
result = TemplateStructureValidator()._validate_reference_codes(template)
frappe.clear_messages()
with self.assertRaises(frappe.ValidationError):
result.notify_user()
message = frappe.get_message_log()[-1]["message"]
self.assertIn("&lt;img", message)
self.assertNotIn("<img", message)

View File

@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
],
primary_action: function () {
var data = d.get_values();
data.company = frm.doc.company;
frappe.call({
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
"creation": "2019-03-07 12:01:56.296952",
@@ -170,7 +171,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:09:52.746196",
"modified": "2026-09-09 17:04:59.512294",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoice Discounting",
@@ -187,14 +188,15 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters: str | dict):
filters = frappe._dict(frappe.parse_json(filters))
if not filters.get("company"):
frappe.throw(_("Please set company on the Document before requesting for invoices."))
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
frappe.has_permission("Invoice Discounting", throw=True)
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")

View File

@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
total_credit += flt(row.credit, precision("credit", row));
});
frm.doc.total_debit = total_debit;
frm.doc.total_credit = total_credit;
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
},

View File

@@ -674,12 +674,14 @@ class JournalEntry(AccountsController):
if d.debit and d.credit:
frappe.throw(_("You cannot credit and debit same account at the same time"))
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
self.total_debit = flt(
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
)
self.total_credit = flt(
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
)
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
self.total_credit, self.precision("total_credit")
)
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
def validate_multi_currency(self):
alternate_currency = []
@@ -1021,6 +1023,11 @@ def get_default_bank_cash_account(
) -> dict:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
# the company is the scope being authorised, and doc= brings User Permissions to bear. `select`,
# not `read`: this also runs server-side from get_payment_entry, and Auditor/HR User/Desk User
# hold only the select row on Company
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
if mode_of_payment:
account = get_bank_cash_account(mode_of_payment, company).get("account")
@@ -1049,6 +1056,11 @@ def get_default_bank_cash_account(
account = account_list[0].name
if account:
# `account` may be named by the caller outright, so authorise the account actually being
# described. get_balance_on() checks this too, but only on the branch that reads a balance,
# and `fetch_balance` is a caller-supplied argument.
frappe.has_permission("Account", doc=account, throw=True)
account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1078,30 +1090,40 @@ def get_against_jv(
if not frappe.db.has_column("Journal Entry", searchfield):
return []
JournalEntry = frappe.qb.DocType("Journal Entry")
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
account = filters.get("account")
party = filters.get("party")
query = (
frappe.qb.from_(JournalEntry)
.join(JournalEntryAccount)
.on(JournalEntryAccount.parent == JournalEntry.name)
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark)
.where(JournalEntryAccount.account == filters.get("account"))
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
.where(JournalEntry.docstatus == 1)
.where(JournalEntry[searchfield].like(f"%{txt}%"))
.orderby(JournalEntry.name, order=frappe.qb.desc)
.limit(page_len)
.offset(start)
# each names one value. A list would be read as a filter operator below and widen the search
# past what the caller named.
for value in (account, party):
if value and not isinstance(value, str):
frappe.throw(_("Invalid filter"), frappe.PermissionError)
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
je_filters = [
["docstatus", "=", 1],
[searchfield, "like", f"%{txt}%"],
["Journal Entry Account", "account", "=", account],
["Journal Entry Account", "reference_type", "is", "not set"],
]
je_filters.append(
["Journal Entry Account", "party", "=", party]
if party
else ["Journal Entry Account", "party", "is", "not set"]
)
party = filters.get("party")
if party:
query = query.where(JournalEntryAccount.party == party)
else:
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
return query.run()
return frappe.get_list(
"Journal Entry",
filters=je_filters,
fields=["name", "posting_date", "remark"],
order_by="name desc",
limit_start=start,
limit_page_length=page_len,
as_list=True,
# one row per entry, not per matching account row. group_by rather than distinct: frappe
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
group_by="name",
)
@frappe.whitelist()

View File

@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
self.check_gl_entries()
def make_jv_with_fractional_totals(self):
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.voucher_type = "Journal Entry"
jv.remark = "test"
for amount in (0.10, 0.20):
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": amount,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 0.30,
},
)
jv.insert()
return jv
def test_totals_are_rounded_to_precision(self):
jv = self.make_jv_with_fractional_totals()
jv.submit()
stored = frappe.db.get_value(
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
)
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
self.assertEqual(jv.total_debit, stored.total_debit)
self.assertEqual(jv.total_credit, stored.total_credit)
self.assertEqual(jv.difference, stored.difference)
def test_update_after_submit_with_fractional_totals(self):
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
jv = self.make_jv_with_fractional_totals()
jv.submit()
jv.pay_to_recd_from = "_Test Supplier"
jv.save()
self.assertEqual(jv.docstatus, 1)
self.assertEqual(
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
)
def test_jv_account_and_party_balance_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_balance_on

View File

@@ -129,6 +129,11 @@ def get_loyalty_program_details(
silent: bool = False,
include_expired_entry: bool = False,
):
# Same guard as get_loyalty_program_details_with_points above: the customer is what the caller
# is entitled to, not the programme. A check on Loyalty Program itself would be read-only to
# System Manager and would deny every role that actually fills in the two calling forms.
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = frappe._dict()
if not loyalty_program:
@@ -150,6 +155,13 @@ def get_loyalty_program_details(
@frappe.whitelist()
def get_redeemption_factor(loyalty_program: str | None = None, customer: str | None = None):
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
frappe.throw(_("Not permitted"), frappe.PermissionError)
if customer:
frappe.has_permission("Customer", doc=customer, throw=True)
customer_loyalty_program = None
if not loyalty_program:
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")

View File

@@ -46,23 +46,27 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.set_query("paid_from", function () {
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
filters.name = ["!=", doc.paid_to];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
}
});
frm.set_query("paid_to", function () {
frm.set_query("paid_to", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
filters.name = ["!=", doc.paid_from];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});

View File

@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.validate_payment_type()
self.validate_internal_transfer_accounts()
self.validate_party_details()
self.set_exchange_rate()
self.validate_mandatory()
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
def update_linked_dunnings(self):
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
update_dunnings_linked_to_payment(self)
def validate_for_repost(self):
validate_docs_for_voucher_types(["Payment Entry"])
validate_docs_for_deferred_accounting([self.name], [])
@@ -315,6 +322,7 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.delink_advance_entry_references()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
def validate_internal_transfer_accounts(self):
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
def validate_party_details(self):
if self.party and not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
@@ -2725,7 +2737,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
pe.append(
"references",
{
@@ -2733,21 +2745,23 @@ def get_payment_entry(
"reference_name": overdue_payment.sales_invoice,
"payment_term": overdue_payment.payment_term,
"due_date": overdue_payment.due_date,
"total_amount": overdue_payment.outstanding,
"outstanding_amount": overdue_payment.outstanding,
"allocated_amount": overdue_payment.outstanding,
"total_amount": outstanding,
"outstanding_amount": outstanding,
"allocated_amount": outstanding,
},
)
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * doc.dunning_amount,
"description": _("Interest and/or dunning fee"),
},
)
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * unpaid_dunning_amount,
"description": _("Interest and/or dunning fee"),
"dunning": doc.name,
},
)
else:
pe.append(
"references",
@@ -3040,8 +3054,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
# only what is left to collect, the totals on the dunning are the ones it was raised with
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
grand_total += doc.get_unpaid_dunning_amount()
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))

View File

@@ -782,6 +782,23 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_internal_transfer_rejects_same_account(self):
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.received_amount = 100
pe.reference_no = "same-account-transfer"
pe.reference_date = nowdate()
self.assertRaisesRegex(
frappe.ValidationError,
"Paid From and Paid To accounts must be different",
pe.insert,
)
def test_bank_charges_deduction(self):
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",

View File

@@ -10,7 +10,8 @@
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
"description",
"dunning"
],
"fields": [
{
@@ -55,12 +56,21 @@
"fieldtype": "Check",
"label": "System Generated",
"read_only": 1
},
{
"fieldname": "dunning",
"fieldtype": "Link",
"label": "Dunning",
"no_copy": 1,
"options": "Dunning",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 14:26:11.312950",
"modified": "2026-08-17 11:20:35.482913",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",

View File

@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
dunning: DF.Link | None
is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -57,9 +57,27 @@ class PaymentOrder(Document):
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
def _readable_payment_order(filters: dict) -> str | None:
"""Authorise the parent before reading its rows.
A child table carries no permissions of its own, so a read of it has to be authorised on the
Payment Order the rows belong to.
"""
parent = filters.get("parent")
if not parent or not frappe.db.exists("Payment Order", parent):
return None
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
return parent
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
@@ -74,6 +92,9 @@ def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={

View File

@@ -1041,40 +1041,6 @@ class TestPaymentRequestV2Gateway(ERPNextTestSuite):
mock_payments.utils = mock_utils
return {"payments": mock_payments, "payments.utils": mock_utils}, mock_utils
def test_is_v2_gateway_returns_false_for_none(self):
"""_is_v2_gateway returns False for None input."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
# Mock returns True, but is_v2_gateway(None) in payments.utils returns False
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway(None)
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with(None)
def test_is_v2_gateway_returns_false_for_empty_string(self):
"""_is_v2_gateway returns False for empty string input."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway("")
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with("")
def test_is_v2_gateway_returns_false_for_nonexistent_gateway(self):
"""_is_v2_gateway returns False for nonexistent gateway."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway("NonExistentGateway12345")
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with("NonExistentGateway12345")
def test_is_v2_gateway_delegates_to_payments_util(self):
"""_is_v2_gateway delegates to payments.utils.is_v2_gateway."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway

View File

@@ -5,7 +5,7 @@ frappe.ui.form.on("Period Closing Voucher", {
onload: function (frm) {
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher", "MapReduce Job"];
},
setup: function (frm) {

View File

@@ -5,9 +5,20 @@
import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
from frappe import _, qb
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Count, Max, Min, Sum
from frappe.utils import (
add_days,
ceil,
cint,
flt,
fmt_money,
formatdate,
get_datetime,
get_link_to_form,
getdate,
)
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
@@ -265,8 +276,17 @@ class PeriodClosingVoucher(AccountsController):
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.make_gl_entries()
else:
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
ppcv.save().submit()
from frappe.utils.background_jobs import mapreduce
data = self.get_data_for_mapreduce()
mapreduce(
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
data,
self.doctype,
self.name,
)
def on_cancel(self):
self.ignore_linked_doctypes = (
@@ -275,11 +295,16 @@ class PeriodClosingVoucher(AccountsController):
"Payment Ledger Entry",
"Account Closing Balance",
"Process Period Closing Voucher",
"MapReduce Job",
)
self.block_if_future_closing_voucher_exists()
self.validate_accounts_not_frozen(for_cancellation=True)
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
from frappe.utils.background_jobs import cancel_mapreduce_job
cancel_mapreduce_job(self.doctype, self.name)
self.cancel_process_pcv_docs()
self.db_set("gle_processing_status", "In Progress")
@@ -292,6 +317,11 @@ class PeriodClosingVoucher(AccountsController):
def on_trash(self):
super().on_trash()
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
from frappe.utils.background_jobs import remove_mapreduce_job
remove_mapreduce_job(self.doctype, self.name)
ppcvs = frappe.db.get_all(
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
)
@@ -594,6 +624,135 @@ class PeriodClosingVoucher(AccountsController):
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
)
def get_data_for_mapreduce(self):
return self.generate_tasks_for_normal_balance() + self.generate_tasks_for_opening_balance()
def get_period_range_for_tasks(self, start_date, end_date, step_size, report_type, balance_type):
start_date = getdate(start_date)
end_date = getdate(end_date)
# split period into date ranges
curr_date = getdate(start_date)
date_splits = []
while True:
next_date = getdate(add_days(curr_date, step_size))
if next_date < end_date:
date_splits.append(
{
"from_date": str(curr_date),
"to_date": str(next_date),
"pcv": self.name,
"report_type": report_type,
"balance_type": balance_type,
}
)
curr_date = getdate(add_days(next_date, 1))
else:
date_splits.append(
{
"from_date": str(curr_date),
"to_date": str(end_date),
"pcv": self.name,
"report_type": report_type,
"balance_type": balance_type,
}
)
break
return date_splits
def generate_tasks_for_normal_balance(self):
# estimation can be wrong by a factor of 2
gl = qb.DocType("GL Entry")
raw_query = (
qb.from_(gl)
.select(Count(gl.star))
.where(
gl.is_cancelled.eq(0) & gl.posting_date.between(self.period_start_date, self.period_end_date)
)
.get_sql()
)
# estimation can be wrong by a factor of 2
correction_factor = 2
if frappe.db.db_type == "postgres":
analyzer = frappe.json.loads(
(
frappe.db.sql(
f"explain (format json) {raw_query}",
)
)[0][0]
)
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
else:
estimated_count = (
cint(
frappe.db.sql(
f"explain {raw_query}",
as_dict=True,
)[0].rows
)
* correction_factor
)
job_count = (
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
) # conservative chunk size
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
return self.get_period_range_for_tasks(
self.period_start_date, self.period_end_date, step_size, "Balance Sheet", "Normal Balance"
) + self.get_period_range_for_tasks(
self.period_start_date, self.period_end_date, step_size, "Profit and Loss", "Normal Balance"
)
def generate_tasks_for_opening_balance(self):
tasks = []
if self.is_first_period_closing_voucher():
gl = qb.DocType("GL Entry")
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
raw_query = (
qb.from_(gl)
.select(Count(gl.star))
.where(gl.is_cancelled.eq(0) & gl.is_opening.eq("Yes") & gl.posting_date.between(min, max))
.get_sql()
)
# estimation can be wrong by a factor of 2
correction_factor = 2
if frappe.db.db_type == "postgres":
analyzer = frappe.json.loads(
(
frappe.db.sql(
f"explain (format json) {raw_query}",
)
)[0][0]
)
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
else:
estimated_count = (
cint(
frappe.db.sql(
f"explain {raw_query};",
as_dict=True,
)[0].rows
)
* correction_factor
)
job_count = (
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
) # conservative chunk size
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
tasks = self.get_period_range_for_tasks(min, max, step_size, "Balance Sheet", "Opening Balance")
return tasks
def process_gl_and_closing_entries(doc):
from erpnext.accounts.general_ledger import make_gl_entries
@@ -673,3 +832,119 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
order_by="period_end_date desc",
)
return prev_closed_period_end_date
def mapper(val):
start_date = val.from_date
end_date = val.to_date
pcv = val.pcv
report_type = val.report_type
balance_type = val.balance_type
company = frappe.db.get_value("Period Closing Voucher", pcv, "company")
dimensions = get_dimensions()
accounts = frappe.db.get_all(
"Account", filters={"company": company, "report_type": report_type}, pluck="name"
)
gle = qb.DocType("GL Entry")
query = qb.from_(gle).select(gle.account)
for dim in dimensions:
query = query.select(gle[dim])
query = query.select(
Sum(gle.debit).as_("debit"),
Sum(gle.credit).as_("credit"),
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
Max(gle.account_currency).as_("account_currency"),
ConstantColumn(balance_type).as_("balance_type"),
ConstantColumn(report_type).as_("report_type"),
).where(
(gle.company.eq(company))
& (gle.is_cancelled.eq(0))
& (gle.posting_date.between(start_date, end_date))
& (gle.account.isin(accounts))
)
if balance_type == "Opening Balance":
query = query.where(gle.is_opening.eq("Yes"))
else:
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
query = query.where(gle.is_opening.eq("No"))
query = query.groupby(gle.account)
for dim in dimensions:
query = query.groupby(gle[dim])
res = query.run(as_dict=True)
return res
def reducer(final, partial_res):
if final is None:
final = []
if partial_res:
final.extend([frappe._dict(x) for x in partial_res])
return final
def get_dimensions():
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
default_dimensions = ["cost_center", "finance_book", "project"]
dimensions = default_dimensions + get_accounting_dimensions()
return dimensions
def summarize_and_post_ledger(result, ref_dt, ref_dn):
pcv = frappe.get_doc(ref_dt, ref_dn)
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
build_dimension_wise_balance_dict,
get_bs_closing_entries,
get_closing_account_closing_entry,
get_gle_for_closing_account,
get_gle_for_pl_account,
get_p_l_closing_entries,
)
result = [frappe._dict(x) for x in result]
# generate and post closing entries for P&L accounts
pl_entries = [x for x in result if x.report_type == "Profit and Loss"]
pl_dimension_wise_acc_balance = build_dimension_wise_balance_dict(pl_entries)
# build gl map
pl_accounts_reverse_gle = []
closing_account_gle = []
for dimensions, account_balances in pl_dimension_wise_acc_balance.items():
for acc, balances in account_balances.items():
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
if balance_in_company_currency:
pl_accounts_reverse_gle.append(get_gle_for_pl_account(pcv, acc, balances, dimensions))
closing_account_gle.append(get_gle_for_closing_account(pcv, account_balances["balances"], dimensions))
gl_entries = pl_accounts_reverse_gle + closing_account_gle
if gl_entries:
from erpnext.accounts.general_ledger import make_gl_entries
make_gl_entries(gl_entries, merge_entries=False)
# generate and post account closing balance for balance sheet accounts
bs_entries = [x for x in result if x.report_type == "Balance Sheet"]
bs_dimension_wise_acc_balance = build_dimension_wise_balance_dict(bs_entries)
pl_closing_entries = get_p_l_closing_entries(pl_accounts_reverse_gle, pcv)
bs_closing_entries = get_bs_closing_entries(bs_dimension_wise_acc_balance, pcv)
closing_entries_for_closing_account = get_closing_account_closing_entry(closing_account_gle, pcv)
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")

View File

@@ -0,0 +1,8 @@
from frappe import _
def get_data():
return {
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
}

View File

@@ -256,6 +256,13 @@ class POSClosingEntry(StatusUpdater):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
pos_profile = filters.get("parent")
if not pos_profile or not frappe.db.exists("POS Profile", pos_profile):
return []
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
frappe.has_permission("POS Profile", ptype, doc=pos_profile, throw=True)
cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"], as_list=1)
return [c for c in cashiers_list]

View File

@@ -909,6 +909,30 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code: str | None, warehouse: str):
# The POS Profile is what entitles a caller to POS stock figures, and it is the only boundary
# that fits: `Item` read and `Bin` read both exclude Accounts Manager, `Item` select is granted
# to every desk user by `Desk User`, and `POS Invoice` read is granted to `All`.
frappe.has_permission("POS Profile", throw=True)
# and keep a company-restricted caller inside their own companies, which costs nobody who has
# no Company User Permission
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "POS Profile")
if allowed_companies:
company = frappe.db.get_value("Warehouse", warehouse, "company")
if company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
# the caller picks the warehouse when allow_warehouse_change is set, and the company check above
# does not narrow within a company; costs nobody who has no Warehouse User Permission
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
if warehouse_permissions := get_user_permissions(frappe.session.user).get("Warehouse"):
allowed_warehouses = get_allowed_docs_for_doctype(warehouse_permissions, "POS Invoice")
if allowed_warehouses and warehouse not in allowed_warehouses:
frappe.throw(_("Not permitted for {0}").format(warehouse), frappe.PermissionError)
if frappe.db.get_value("Item", item_code, "is_stock_item"):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)

View File

@@ -108,7 +108,7 @@ frappe.ui.form.on("Pricing Rule", {
</td></tr>
</table>`;
frm.set_df_property("pricing_rule_help", "options", help_content);
frm.get_field("pricing_rule_help").html(help_content);
frm.events.set_options_for_applicable_for(frm);
frm.trigger("toggle_reqd_apply_on");
},

View File

@@ -12,6 +12,29 @@ from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import cint, flt
# the transactions the pricing engine is called for, from transaction.js and the POS
PRICING_TRANSACTION_DOCTYPES = frozenset(
{
"Quotation",
"Sales Order",
"Delivery Note",
"Sales Invoice",
"POS Invoice",
"Supplier Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
"Material Request",
# these three also extend a controller that calls the pricing engine: BOM and BOM Creator
# through TransactionController, Request for Quotation through BuyingController
"BOM",
"BOM Creator",
"Request for Quotation",
# no client sends this one, but set_transaction_type below still branches on it
"Opportunity",
}
)
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
other_fields = ["other_item_code", "other_item_group", "other_brand"]
@@ -366,6 +389,30 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
args = frappe._dict(args)
# `args` is caller supplied, and what comes back is pricing: matched Pricing Rules, discounts
# and rates. The transaction being priced is what decides who may price it, so authorise that
# — and the document itself where the caller named an existing one, so User Permissions apply.
# an allow-list, not just a type check: `doctype` is caller-chosen, and any doctype the caller can
# read would otherwise satisfy has_permission below while the pricing engine still ran
transaction_doctype = args.get("doctype")
if transaction_doctype not in PRICING_TRANSACTION_DOCTYPES:
frappe.throw(_("Invalid doctype"), frappe.PermissionError)
transaction_name = args.get("name")
if not isinstance(transaction_name, str) or not frappe.db.exists(transaction_doctype, transaction_name):
transaction_name = None
frappe.has_permission(transaction_doctype, doc=transaction_name, throw=True)
# scope by the caller's own Company restrictions, not a Company read: several roles that fill these forms hold none
company = args.get("company")
if company:
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, transaction_doctype)
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
set_transaction_type(args)
# list of dictionaries
@@ -725,14 +772,18 @@ def set_transaction_type(pricing_ctx: frappe._dict) -> None:
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_item_uoms(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
items = [filters.get("value")]
if filters.get("apply_on") != "Item Code":
field = frappe.scrub(filters.get("apply_on"))
items = [d.name for d in frappe.db.get_all("Item", filters={field: filters.get("value")})]
if filters.get("apply_on") == "Item Code":
item_filters = [["name", "=", filters.get("value")]]
else:
item_filters = [[frappe.scrub(filters.get("apply_on")), "=", filters.get("value")]]
items = frappe.get_list("Item", filters=item_filters, pluck="name")
if not items:
return []
return frappe.get_all(
"UOM Conversion Detail",
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
fields=["uom"],
as_list=1,
distinct=True,

View File

@@ -133,14 +133,17 @@ def initialize_parallel_threads(docname: str):
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def start_pcv_processing(docname: str):
# checked before the status is read, not inside the branch: otherwise an unentitled caller
# learns the document's status from whether this returns or throws
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
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)
initialize_parallel_threads(docname)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
@@ -157,7 +160,7 @@ def pause_pcv_processing(docname: str):
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
@@ -173,7 +176,7 @@ def cancel_pcv_processing(docname: str):
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
@@ -258,8 +261,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
return gl_entry
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def schedule_next_date(docname: str):
# marks a row Running and enqueues a long job, so it needs the same write check as the sibling controls
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")

View File

@@ -320,6 +320,13 @@ def get_html(doc, filters, entry, col, res, ageing):
from frappe.www.printview import get_letter_head
letter_head = get_letter_head(doc, 0)
# render letter head content as a template so its Jinja resolves against the doc
if letter_head.get("content"):
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
letter_head["content"] = frappe.render_template(letter_head["content"], {"doc": doc})
if letter_head.get("footer"):
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
letter_head["footer"] = frappe.render_template(letter_head["footer"], {"doc": doc})
html = frappe.render_template(
template_path,
{

View File

@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
const me = this;
super.refresh();
hide_fields(this.frm.doc);
hide_fields(this.frm);
// Show / Hide button
this.show_general_ledger();
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
@@ -418,7 +418,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
is_paid() {
hide_fields(this.frm.doc);
hide_fields(this.frm);
if (cint(this.frm.doc.is_paid)) {
this.frm.set_value("allocate_advances_automatically", 0);
this.frm.set_value("payment_terms_template", "");
@@ -482,28 +482,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
// Hide Fields
// ------------
function hide_fields(doc) {
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
function hide_fields(frm) {
const doc = frm.doc;
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
if (cint(doc.is_paid) == 1) {
hide_field(parent_fields);
frm.toggle_display(parent_fields, false);
} else {
for (var i in parent_fields) {
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
if (!docfield.hidden) unhide_field(parent_fields[i]);
for (const fieldname of parent_fields) {
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
if (!docfield.hidden) frm.toggle_display(fieldname, true);
}
}
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
if (cur_frm.fields_dict["items"]) {
cur_frm.fields_dict["items"].grid.set_column_disp(
item_fields_stock,
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
);
if (frm.fields_dict["items"]) {
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
}
cur_frm.refresh_fields();
frm.refresh_fields();
}
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
@@ -712,7 +710,7 @@ frappe.ui.form.on("Purchase Invoice", {
},
update_stock: function (frm) {
hide_fields(frm.doc);
hide_fields(frm);
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
},

View File

@@ -3061,6 +3061,23 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_partial_returns_ignore_received_qty_without_update_stock(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
invoice = make_purchase_invoice(qty=10, received_qty=10)
first_return = make_return_doc(invoice.doctype, invoice.name)
first_return.items[0].qty = -4
first_return.save().submit()
self.assertEqual(first_return.items[0].received_qty, -10)
second_return = make_return_doc(invoice.doctype, invoice.name)
second_return.items[0].qty = -6
second_return.save().submit()
self.assertEqual(second_return.docstatus, 1)
def test_apply_discount_on_grand_total(self):
"""
To test if after applying discount on grand total,

View File

@@ -514,6 +514,11 @@ def validate_docs_for_voucher_types(doc_voucher_types):
def get_repost_allowed_types(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
):
# Reached only from this doctype's own form (repost_accounting_ledger.js:8), and Repost
# Accounting Ledger is System-Manager-only, so the form is the boundary. `filters` is passed
# straight to db.get_all, which is why reaching it needs to be gated rather than merely typed.
frappe.has_permission("Repost Accounting Ledger", throw=True)
if txt:
filters.update({"document_type": ("like", f"%{txt}%")})

View File

@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
def execute_repost_payment_ledger(docname: str):
"""Repost Payment Ledger Entries by background job."""
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
job_name = "payment_ledger_repost_" + docname
frappe.enqueue(

View File

@@ -376,9 +376,26 @@ def _apply_sales_party_details(target_doc, source_doc, details):
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
# The only two targets this resolves a reference field for. Stating them rejects a caller
# supplied doctype that would otherwise be filtered on a column it does not have.
reference_fields = {
"Purchase Invoice": ("inter_company_invoice_reference", "Sales Invoice"),
"Purchase Order": ("inter_company_order_reference", "Sales Order"),
}
if doctype not in reference_fields:
frappe.throw(_("Invalid doctype {0}").format(doctype), frappe.PermissionError)
reference_field, source_doctype = reference_fields[doctype]
# `reference_name` is the caller's own document. The targets belong to the counterpart company
# and the caller legitimately may not be able to read them, so the source is what decides
# access here rather than the doctype being counted. doc= brings User Permissions in.
frappe.has_permission(source_doctype, doc=reference_name, throw=True)
# `reference_fieldname` is selected as a column below and its value becomes the result key,
# so an unchecked one returns any field of the item table to the caller.
if not frappe.get_meta(doctype + " Item").has_field(reference_fieldname):
frappe.throw(_("Invalid field {0}").format(reference_fieldname), frappe.PermissionError)
filters = {
reference_field: reference_name,

View File

@@ -52,7 +52,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
create_stock_reconciliation,
)
from erpnext.stock.get_item_details import get_item_tax_map
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
from erpnext.stock.utils import _get_incoming_rate, get_stock_balance
from erpnext.tests.utils import ERPNextTestSuite
@@ -3356,7 +3356,7 @@ class TestSalesInvoice(ERPNextTestSuite):
rate = 0.0
for d in si.get("items"):
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.warehouse,

View File

@@ -9,10 +9,15 @@ frappe.ui.form.on("Shipping Rule", {
},
company: function (frm) {
if (frm.previous_company !== frm.doc.company) {
frm.previous_company = frm.doc.company;
frm.set_value("account", "");
}
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
},
refresh: function (frm) {
frm.previous_company = frm.doc.company;
frm.set_query("account", function () {
return {
filters: {

View File

@@ -52,10 +52,23 @@ class ShippingRule(Document):
# end: auto-generated types
def validate(self):
self.validate_account_company()
self.validate_from_to_values()
self.sort_shipping_rule_conditions()
self.validate_overlapping_shipping_rule_conditions()
def validate_account_company(self):
if not self.company or not self.account:
return
if frappe.get_cached_value("Account", self.account, "company") != self.company:
throw(
_("Shipping Account {0} does not belong to Company {1}").format(
frappe.bold(self.account), frappe.bold(self.company)
),
title=_("Invalid Shipping Account"),
)
def validate_from_to_values(self):
if self.calculate_based_on == "Fixed":
if self.conditions:

View File

@@ -15,6 +15,37 @@ class TestShippingRule(ERPNextTestSuite):
def setUp(self):
self.load_test_records("Shipping Rule")
def test_account_company_on_insert(self):
for rule_type in ("Selling", "Buying"):
with self.subTest(shipping_rule_type=rule_type):
shipping_rule = frappe.copy_doc(self.globalTestRecords["Shipping Rule"][0])
shipping_rule.label = f"{rule_type} Delivery"
shipping_rule.shipping_rule_type = rule_type
shipping_rule.company = "_Test Company 1"
shipping_rule.cost_center = None
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.insert()
def test_account_company_on_update(self):
shipping_rule = create_shipping_rule("Selling", "Standard Delivery")
shipping_rule.company = "_Test Company 1"
shipping_rule.cost_center = None
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.save()
shipping_rule.reload()
shipping_rule.company = "_Test Company 1"
shipping_rule.account = "_Test Account Shipping Charges - _TC1"
shipping_rule.cost_center = None
shipping_rule.save()
shipping_rule.reload()
self.assertEqual(shipping_rule.company, "_Test Company 1")
self.assertEqual(shipping_rule.account, "_Test Account Shipping Charges - _TC1")
shipping_rule.account = "_Test Account Shipping Charges - _TC"
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.save()
def test_from_greater_than_to(self):
shipping_rule = frappe.copy_doc(self.globalTestRecords["Shipping Rule"][0])
shipping_rule.name = self.globalTestRecords["Shipping Rule"][0].get("name")

View File

@@ -145,6 +145,12 @@ def get_party_details(party: str | None, party_type: str, args: dict | None = No
out = {}
billing_address, shipping_address = None, None
if args:
# each of these names a single Address. A dict is read as a filter instead, and `get_doc`
# would resolve it to whichever Address happens to match, so only a plain name is accepted
for fieldname in ("billing_address", "shipping_address"):
if args.get(fieldname) and not isinstance(args.get(fieldname), str):
frappe.throw(_("Invalid address"), frappe.PermissionError)
if args.get("billing_address"):
billing_address = frappe.get_doc("Address", args.get("billing_address"))
if args.get("shipping_address"):

View File

@@ -26,6 +26,7 @@ import erpnext
from erpnext import get_company_currency
from erpnext.accounts.utils import get_fiscal_year
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
from erpnext.utilities.regional import temporary_flag
try:
@@ -394,12 +395,17 @@ def set_other_values(party_details, party, party_type):
def get_default_price_list(party):
"""Return default price list for party (Document object)"""
if party.get("default_price_list"):
return party.default_price_list
"""Return the first enabled default price list for party (Document object)"""
price_list = party.get("default_price_list")
if is_price_list_enabled(price_list):
return price_list
if party.doctype == "Customer":
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if party.doctype != "Customer":
return
price_list = frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if is_price_list_enabled(price_list):
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
@@ -412,7 +418,7 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
if customer_price_list:
if is_price_list_enabled(customer_price_list):
price_list = customer_price_list
else:
pos_price_list = frappe.get_value("POS Profile", pos, "selling_price_list")
@@ -420,6 +426,9 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
else:
price_list = get_default_price_list(party) or given_price_list
if price_list and not is_price_list_enabled(price_list):
price_list = None
if price_list:
party_details.price_list_currency = frappe.db.get_value(
"Price List", price_list, "currency", cache=True

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

@@ -171,6 +171,7 @@ class ReceivablePayableReport:
party_account=ple.account,
posting_date=ple.posting_date,
account_currency=ple.account_currency,
cost_center=ple.cost_center,
remarks=ple.remarks,
invoiced=0.0,
paid=0.0,

View File

@@ -1337,6 +1337,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
def test_cost_center_on_payment_before_invoice(self):
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.posting_date = add_days(today(), 1)
si.due_date = si.posting_date
si.payment_schedule[0].due_date = si.posting_date
si.save().submit()
pe = self.create_payment_entry(si.name, do_not_submit=True)
pe.cost_center = self.cost_center
pe.save().submit()
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
self.assertEqual(row.cost_center, pe.cost_center)
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms

View File

@@ -28,16 +28,16 @@
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
{% } %}
</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } else { %}
<tr>
<td></td>
<td></td>
<td>{%= data[i]["payment_entry"] %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } %}
{% } %}

View File

@@ -114,6 +114,7 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
"company": filters.company,
},
pluck="name",
)

View File

@@ -16,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
def execute(filters=None):
@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
# removing the duplicate Item Code column and moving Item Name before Customer
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
del columns[4:6]
del columns[4]
columns.insert(1, columns.pop(4))
else:
del columns[5:7]
del columns[5]
columns.insert(1, columns.pop(5))
total_base_amount = 0
total_buying_amount = 0
@@ -967,7 +969,7 @@ class GrossProfitGenerator:
if row.serial_and_batch_bundle:
args.update({"serial_and_batch_bundle": row.serial_and_batch_bundle})
average_buying_rate = get_incoming_rate(args)
average_buying_rate = _get_incoming_rate(args)
self.average_buying_rate[key] = flt(average_buying_rate)
return self.average_buying_rate[key]

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
from frappe.query_builder.functions import Coalesce, Min, Sum
from frappe.utils import cstr
@@ -47,19 +47,23 @@ def get_columns(filters):
def get_pos_sales_payment_data(filters):
sales_invoice_data = get_pos_invoice_data(filters)
data = [
[
row["posting_date"],
row["owner"],
row["mode_of_payment"],
row["net_total"],
row["total_taxes"],
row["paid_amount"],
row["warehouse"],
row["cost_center"],
]
for row in sales_invoice_data
]
labels = get_pos_row_labels(filters)
data = []
for row in sales_invoice_data:
label = labels.get(get_pos_row_key(row)) or frappe._dict()
data.append(
[
row["posting_date"],
row["owner"],
label.mode_of_payment,
row["net_total"],
row["total_taxes"],
row["paid_amount"],
row["warehouse"],
label.cost_center,
]
)
return data
@@ -123,25 +127,17 @@ def apply_conditions(query, a, filters):
return query
def get_pos_invoice_data(filters):
def get_invoice_item_totals():
"""One row per invoice: summed item base_total, plus warehouse and cost_center off its first line."""
sii = frappe.qb.DocType("Sales Invoice Item")
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse and cost_center describe an
# item line, not the invoice, and an invoice may carry several. warehouse then becomes an outer
# grouping key below, so which line wins decides how rows are partitioned and what each row totals
# -- not merely which label is shown. Max() over text is a sort, and MariaDB (case-folding) and
# PostgreSQL (byte order) resolve it differently, so take both off one real line instead.
# The representative is the first line the user entered: Min(idx) is an integer, so the pick is
# free of collation and is meaningful, rather than turning on an unrelated hash-named row.
grouped_items = (
frappe.qb.from_(sii)
.select(sii.parent, Sum(sii.amount).as_("base_total"), Min(sii.idx).as_("representative_idx"))
.groupby(sii.parent)
).as_("grouped_items")
representative_item = frappe.qb.DocType("Sales Invoice Item").as_("representative_item")
t1 = (
return (
frappe.qb.from_(grouped_items)
.inner_join(representative_item)
.on(
@@ -156,24 +152,12 @@ def get_pos_invoice_data(filters):
)
)
# t3: mode_of_payment per invoice, from one real payment line for the same reason
grouped_payments = (
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
).as_("grouped_payments")
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
t3 = (
frappe.qb.from_(grouped_payments)
.inner_join(representative_payment)
.on(
(representative_payment.parent == grouped_payments.parent)
& (representative_payment.idx == grouped_payments.representative_idx)
)
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
a = (
def get_invoice_totals():
"""Invoice-level aggregates, grouped by the primary key so every plain column is dependent."""
si = frappe.qb.DocType("Sales Invoice")
return (
frappe.qb.from_(si)
.select(
si.docstatus,
@@ -183,6 +167,7 @@ def get_pos_invoice_data(filters):
si.name,
si.posting_date,
si.owner,
si.creation,
Sum(si.base_total).as_("base_total"),
Sum(si.net_total).as_("net_total"),
Sum(si.total_taxes_and_charges).as_("total_taxes"),
@@ -192,12 +177,76 @@ def get_pos_invoice_data(filters):
.groupby(si.name)
)
def get_pos_row_key(row):
return (row.owner, row.posting_date, row.warehouse)
def get_representative_payments():
"""One payment line per invoice: the first the user entered."""
sip = frappe.qb.DocType("Sales Invoice Payment")
grouped_payments = (
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
).as_("grouped_payments")
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
return (
frappe.qb.from_(grouped_payments)
.inner_join(representative_payment)
.on(
(representative_payment.parent == grouped_payments.parent)
& (representative_payment.idx == grouped_payments.representative_idx)
)
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
)
def get_pos_row_labels(filters):
"""cost_center and mode_of_payment off the earliest invoice in each row.
Ordered in Python rather than SQL, so no database collation applies to the tie-break.
"""
t1 = get_invoice_item_totals()
t3 = get_representative_payments()
a = get_invoice_totals()
query = (
frappe.qb.from_(t1)
.left_join(t3)
.on(t3.parent == t1.parent)
.join(a)
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
.select(
a.owner,
a.posting_date,
a.creation,
a.name,
t1.warehouse,
t1.cost_center,
t3.mode_of_payment,
)
.where(a.docstatus == 1)
)
query = apply_conditions(query, a, filters)
labels = {}
for row in query.run(as_dict=True):
key = get_pos_row_key(row)
current = labels.get(key)
if current is None or (row.creation, row.name) < (current.creation, current.name):
labels[key] = row
return labels
def get_pos_invoice_data(filters):
t1 = get_invoice_item_totals()
a = get_invoice_totals()
query = (
frappe.qb.from_(t1)
.join(a)
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
.select(
a.posting_date,
a.owner,
@@ -205,10 +254,7 @@ def get_pos_invoice_data(filters):
Sum(a.total_taxes).as_("total_taxes"),
Sum(a.paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
Max(t3.mode_of_payment).as_("mode_of_payment"),
t1.warehouse,
Max(t1.cost_center).as_("cost_center"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date, t1.warehouse)

View File

@@ -9,6 +9,8 @@ from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import
get_mode_of_payment_details,
get_mode_of_payments,
get_pos_invoice_data,
get_pos_row_key,
get_pos_row_labels,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -94,12 +96,43 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
posted = {(row.warehouse, row.cost_center) for row in si.items}
self.assertGreater(len(posted), 1, "fixture must post more than one distinct pair")
labels = get_pos_row_labels(get_filters())
rows = get_pos_invoice_data(get_filters())
reported = [r for r in rows if r.get("warehouse") in {w for w, _ in posted}]
self.assertTrue(reported)
for row in reported:
self.assertIn((row["warehouse"], row["cost_center"]), posted)
label = labels[get_pos_row_key(row)]
self.assertIn((row["warehouse"], label.cost_center), posted)
def test_pos_row_labels_come_from_the_earliest_invoice(self):
"""The reported cost centre and payment mode must be one invoice's, and the same one's.
A row covers every invoice sharing an owner, date and warehouse, so neither column describes
it. Aggregating each independently sorts text -- which the two engines resolve differently --
and can pair one invoice's cost centre with another's payment mode.
"""
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
warehouse = create_warehouse("_Test POS Row Labels")
card = create_mode_of_payment("_Test POS Card", "_Test Bank - _TC")
# cross the two picks: the earlier invoice holds the lower cost centre and the higher mode
posted = [("Main - _TC", card, "_Test Bank - _TC"), ("Sub - _TC", "Cash", "_Test Cash - _TC")]
for cost_center, mode_of_payment, account in posted:
si = create_sales_invoice_record()
si.is_pos = 1
si.items[0].warehouse = warehouse
si.items[0].cost_center = cost_center
si.append("payments", {"mode_of_payment": mode_of_payment, "account": account, "amount": 10000})
si.insert()
si.submit()
rows = [row for row in get_pos_invoice_data(get_filters()) if row.get("warehouse") == warehouse]
self.assertEqual(len(rows), 1, "the reported row count must not change")
label = get_pos_row_labels(get_filters())[get_pos_row_key(rows[0])]
self.assertEqual((label.cost_center, label.mode_of_payment), ("Main - _TC", card))
def test_get_mode_of_payments_details(self):
filters = get_filters()
@@ -182,6 +215,21 @@ def get_filters():
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}
def create_mode_of_payment(name, account, company="_Test Company"):
"""A POS payment row needs its mode to carry a default account for the company."""
if not frappe.db.exists("Mode of Payment", name):
frappe.get_doc(
{
"doctype": "Mode of Payment",
"mode_of_payment": name,
"type": "Bank",
"accounts": [{"company": company, "default_account": account}],
}
).insert()
return name
def create_sales_invoice_record(qty=1):
# return sales invoice doc object
return frappe.get_doc(

View File

@@ -181,6 +181,27 @@ class TaxService:
return amount, base_amount
# the only doctypes a `taxes_and_charges` Link points at; `master_doctype` is caller-supplied and reaches get_doc()
TAX_MASTER_DOCTYPES = ("Sales Taxes and Charges Template", "Purchase Taxes and Charges Template")
def validate_tax_master(master_doctype: str, master_name: str | None = None) -> None:
if master_doctype not in TAX_MASTER_DOCTYPES:
frappe.throw(_("Invalid tax master doctype"), frappe.PermissionError)
if not master_name:
return
# keep a company-restricted caller inside their own companies; this does NOT authorise the template itself
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, master_doctype)
if allowed_companies:
company = frappe.db.get_value(master_doctype, master_name, "company")
if company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
@frappe.whitelist()
def get_tax_rate(account_head: str) -> dict:
return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
@@ -193,6 +214,8 @@ def get_default_taxes_and_charges(
if not company:
return {}
validate_tax_master(master_doctype, tax_template)
if tax_template and company:
tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
if tax_template_company == company:
@@ -210,6 +233,9 @@ def get_default_taxes_and_charges(
def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None:
if not master_name:
return
validate_tax_master(master_doctype, master_name)
from frappe.model import child_table_fields, default_fields
tax_master = frappe.get_doc(master_doctype, master_name)

View File

@@ -1,6 +1,6 @@
import frappe
from erpnext.accounts.party import get_default_price_list
from erpnext.accounts.party import get_default_price_list, set_price_list
from erpnext.tests.utils import ERPNextTestSuite
@@ -16,3 +16,46 @@ class PartyTestCase(ERPNextTestSuite):
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None
def test_disabled_party_default_should_fall_back_to_given_price_list(self):
customer = self.create_customer(default_price_list=self.create_price_list(enabled=0))
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_disabled_given_price_list_should_not_be_set(self):
customer = self.create_customer()
party_details = frappe._dict()
set_price_list(party_details, customer, "Customer", self.create_price_list(enabled=0))
self.assertIsNone(party_details.selling_price_list)
def create_price_list(self, enabled):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": 1,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_customer(self, **values):
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
customer.customer_group = None
customer.save()
return customer

View File

@@ -2415,8 +2415,8 @@ class QueryPaymentLedger:
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
).as_("grouped")
# Payment Ledger Entry has no autoname rule, so frappe names it by hash -- lower-case, which
# keeps Min(name) free of the collation divergence that picking Max() over free text has.
# KNOWN DIVERGENCE: Min(name) is a text sort. Hash names are not reliably lower case -- the
# trace-id prefix is not lowered -- so the engines can pick different rows here.
representative_ple = qb.DocType("Payment Ledger Entry").as_("representative_ple")
query_voucher_amount = (
qb.from_(grouped_voucher_amount)

View File

@@ -546,15 +546,22 @@ frappe.ui.form.on("Asset", {
},
set_finance_book: function (frm) {
let item_code = frm.doc.item_code;
let net_purchase_amount = frm.doc.net_purchase_amount;
frappe.call({
method: "erpnext.assets.doctype.asset.asset.get_item_details",
args: {
item_code: frm.doc.item_code,
item_code: item_code,
asset_category: frm.doc.asset_category,
net_purchase_amount: frm.doc.net_purchase_amount,
net_purchase_amount: net_purchase_amount,
},
callback: function (r, rt) {
if (r.message) {
if (
r.message &&
frm.doc.item_code === item_code &&
frm.doc.net_purchase_amount === net_purchase_amount
) {
frm.set_value("finance_books", r.message);
}
},
@@ -752,10 +759,12 @@ frappe.ui.form.on("Asset", {
},
net_purchase_amount: function (frm) {
if (frm.doc.finance_books) {
if (frm.doc.finance_books && frm.doc.finance_books.length) {
frm.doc.finance_books.forEach((d) => {
frm.events.set_depreciation_rate(frm, d);
});
} else if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
frm.trigger("set_finance_book");
}
},

View File

@@ -1171,15 +1171,36 @@ def get_asset_value_after_depreciation(
asset_name: str,
finance_book: str | None = None,
):
# one of the three calling forms is the boundary; Asset itself excludes the roles holding Asset Value Adjustment write
if not any(
frappe.has_permission(dt, "write")
for dt in ("Asset Value Adjustment", "Asset Capitalization", "Asset Repair")
):
frappe.throw(_("Not permitted"), frappe.PermissionError)
asset = frappe.get_doc("Asset", asset_name)
_check_asset_company(asset.company)
if not asset.calculate_depreciation:
return flt(asset.value_after_depreciation)
return asset.get_value_after_depreciation(finance_book)
def _check_asset_company(company: str | None) -> None:
"""Keep a company-restricted caller inside their own companies; a no-op for everyone else."""
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Asset")
if allowed_companies and company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
@frappe.whitelist()
def has_active_capitalization(asset: str):
frappe.has_permission("Asset", doc=asset, throw=True)
active_capitalizations = frappe.db.count(
"Asset Capitalization", filters={"target_asset": asset, "docstatus": 1}
)
@@ -1192,7 +1213,21 @@ def get_values_from_purchase_doc(
item_code: str,
doctype: str,
):
# `doctype` is caller-supplied and reaches frappe.get_doc() as the doctype itself, so without
# this list any document with an `items` table could be read for its valuation rates. The two
# values below are the only ones this function handles — see the branches further down.
if doctype not in ("Purchase Receipt", "Purchase Invoice"):
frappe.throw(_("Invalid document type"), frappe.PermissionError)
# The caller is filling in an Asset (asset.js:794), and the Asset form is the boundary: Quality
# Manager writes Assets but holds read on neither Purchase Receipt nor Purchase Invoice, so the
# purchase document cannot be it.
frappe.has_permission("Asset", "write", throw=True)
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
_check_asset_company(purchase_doc.company)
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
if not matching_items:

View File

@@ -29,7 +29,7 @@ from erpnext.stock.get_item_details import (
get_item_warehouse_,
)
from erpnext.stock.stock_ledger import get_previous_sle
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate, check_warehouse_company
force_fields = [
"target_item_name",
@@ -191,7 +191,7 @@ class AssetCapitalization(StockController):
cumulative_qty += flt(d.stock_qty)
args = self.get_args_for_incoming_rate(d)
args["qty"] = -1 * cumulative_qty
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
cumulative_rate = flt(_get_incoming_rate(args, raise_error_if_no_rate=False))
cumulative_value = cumulative_rate * cumulative_qty
row_value = cumulative_value - prev_cumulative_value
@@ -326,6 +326,8 @@ class AssetCapitalization(StockController):
@frappe.whitelist()
def set_warehouse_details(self):
self.check_permission("write")
for d in self.get("stock_items"):
if d.item_code and d.warehouse:
args = self.get_args_for_incoming_rate(d)
@@ -336,6 +338,8 @@ class AssetCapitalization(StockController):
@frappe.whitelist()
def set_asset_values(self):
self.check_permission("write")
for d in self.get("asset_items"):
if d.asset:
finance_book = d.get("finance_book") or self.get("finance_book")
@@ -511,8 +515,24 @@ class AssetCapitalization(StockController):
)
def check_capitalization_access(company: str | None = None) -> None:
"""Every lookup in this file feeds the Asset Capitalization form, so that form is the boundary."""
frappe.has_permission("Asset Capitalization", throw=True)
if not isinstance(company, str) or not company:
return
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Asset Capitalization")
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
@frappe.whitelist()
def get_target_item_details(item_code: str | None = None, company: str | None = None):
check_capitalization_access(company)
out = frappe._dict()
# Get Item Details
@@ -539,6 +559,8 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
@frappe.whitelist()
def get_target_asset_details(asset: str | None = None, company: str | None = None):
check_capitalization_access(company)
out = frappe._dict()
# Get Asset Details
@@ -624,10 +646,12 @@ def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
frappe.has_permission("Item", doc=ctx.item_code, throw=True)
frappe.has_permission("Warehouse", doc=ctx.warehouse, throw=True)
frappe.has_permission("Stock Ledger Entry", throw=True)
# inherited from get_incoming_rate before the split; _get_incoming_rate does not scope
check_warehouse_company(ctx.warehouse)
out = frappe._dict(
{
"actual_qty": get_previous_sle(ctx).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(ctx, raise_error_if_no_rate=False),
"valuation_rate": _get_incoming_rate(ctx, raise_error_if_no_rate=False),
}
)
return out
@@ -636,6 +660,8 @@ def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_consumed_asset_details(ctx: ItemDetailsCtx) -> frappe._dict:
check_capitalization_access(ctx.get("company"))
out = frappe._dict()
asset_details = frappe._dict()
@@ -682,6 +708,8 @@ def get_consumed_asset_details(ctx: ItemDetailsCtx) -> frappe._dict:
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
check_capitalization_access(ctx.get("company"))
out = frappe._dict()
item = frappe._dict()
@@ -706,6 +734,8 @@ def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
def get_items_tagged_to_wip_composite_asset(params: dict | str):
params = frappe.parse_json(params)
check_capitalization_access(params.get("company") if isinstance(params, dict | frappe._dict) else None)
fields = [
"item_code",
"item_name",

View File

@@ -351,6 +351,20 @@ class AssetRepair(AccountsController):
add_asset_activity(self.asset, subject)
def check_asset_repair_access(company: str | None = None) -> None:
"""Both pickers below sit on the Asset Repair form, so that form is the boundary, not Purchase Invoice."""
frappe.has_permission("Asset Repair", throw=True)
if not isinstance(company, str) or not company:
return
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Asset Repair")
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
@frappe.whitelist()
def get_downtime(failure_date: DateTimeLikeObject, completion_date: DateTimeLikeObject):
downtime = time_diff_in_hours(completion_date, failure_date)
@@ -371,6 +385,8 @@ def get_purchase_invoice(
Get Purchase Invoices that have expense accounts for non-stock items.
Only returns invoices with at least one non-stock, non-fixed-asset item with an expense account.
"""
check_asset_repair_access(filters.get("company") if isinstance(filters, dict) else None)
pi = DocType("Purchase Invoice")
pi_item = DocType("Purchase Invoice Item")
item = DocType("Item")
@@ -413,6 +429,8 @@ def get_expense_accounts(
Get expense accounts for non-stock (service) items from the purchase invoice.
Used as a query function for link fields.
"""
check_asset_repair_access()
purchase_invoice = filters.get("purchase_invoice")
if not purchase_invoice:
return []

View File

@@ -581,7 +581,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
var item_length = me.frm.doc.items.length;
while (i < item_length) {
var qty = me.frm.doc.items[i].qty;
(r.message[0] || []).forEach(function (d) {
(r.message || []).forEach(function (d) {
if (
d.qty > 0 &&
qty > 0 &&

View File

@@ -226,6 +226,7 @@ class PurchaseOrder(BuyingController):
self.doctype, self.supplier, self.company, self.inter_company_order_reference
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.set_missing_terms()
def set_has_unit_price_items(self):
"""
@@ -610,14 +611,19 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
return item_last_purchase_rate
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def close_or_unclose_purchase_orders(names: str | list, status: str):
if not frappe.has_permission("Purchase Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.has_permission("Purchase Order", "write", throw=True)
names = frappe.parse_json(names)
for name in names:
po = frappe.get_lazy_doc("Purchase Order", name)
if not isinstance(name, str):
frappe.throw(_("Invalid name"), frappe.PermissionError)
# the check above is doctype level and never consults User Permissions, so on its own it
# lets a caller restricted to one company close another company's orders. Checking each
# document is what scopes it, and matches what update_status() below already does.
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
if po.docstatus == 1:
if status == "Closed":
if po.status not in ("Cancelled", "Closed") and (
@@ -648,7 +654,7 @@ def get_list_context(context=None):
return list_context
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_status(status: str, name: str):
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
po.update_status(status)

View File

@@ -8,7 +8,6 @@ from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
from frappe.query_builder import Order
from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
@@ -481,32 +480,34 @@ def get_supplier_tag():
def get_rfq_containing_supplier(
doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict
):
rfq = frappe.qb.DocType("Request for Quotation")
rfq_supplier = frappe.qb.DocType("Request for Quotation Supplier")
rfq_filters = [
["docstatus", "=", 1],
["company", "=", filters.get("company")],
]
query = (
frappe.qb.from_(rfq)
.from_(rfq_supplier)
.select(rfq.name)
.distinct()
.select(rfq.transaction_date, rfq.company)
.where(
(rfq.name == rfq_supplier.parent)
& (rfq_supplier.supplier == filters.get("supplier"))
& (rfq.docstatus == 1)
& (rfq.company == filters.get("company"))
if frappe.has_permission("Request for Quotation", "read"):
rfq_filters.append(["Request for Quotation Supplier", "supplier", "=", filters.get("supplier")])
else:
parents = frappe.get_all(
"Request for Quotation Supplier",
filters={"supplier": filters.get("supplier"), "parenttype": "Request for Quotation"},
pluck="parent",
distinct=True,
)
.orderby(rfq.transaction_date, order=Order.asc)
.limit(page_len)
.offset(start)
)
rfq_filters.append(["name", "in", parents or [""]])
if txt:
query = query.where(rfq.name.like(f"%%{txt}%%"))
rfq_filters.append(["name", "like", f"%{txt}%"])
if filters.get("transaction_date"):
query = query.where(rfq.transaction_date == filters.get("transaction_date"))
rfq_filters.append(["transaction_date", "=", filters.get("transaction_date")])
rfq_data = query.run(as_dict=1)
return rfq_data
return frappe.get_list(
"Request for Quotation",
filters=rfq_filters,
fields=["name", "transaction_date", "company"],
group_by="name",
order_by="transaction_date asc",
limit_start=start,
limit_page_length=page_len,
)

View File

@@ -242,6 +242,16 @@ def get_supplier_primary(
):
supplier = filters.get("supplier")
type = filters.get("type")
# `type` is caller-supplied and was interpolated straight into qb.DocType(), so any doctype on
# the site could be joined to Dynamic Link and read. The two pickers that call this
# (supplier.js:51,61) send only these two values.
if type not in ("Contact", "Address"):
frappe.throw(_("Invalid type"), frappe.PermissionError)
# authorise the party, not Contact/Address: the `if_owner` row on Address would empty the picker rather than error
frappe.has_permission("Supplier", doc=supplier, throw=True)
type_doctype = frappe.qb.DocType(type)
dynamic_link = frappe.qb.DocType("Dynamic Link")

View File

@@ -146,11 +146,7 @@ class SupplierScorecard(Document):
frappe.db.set_value("Supplier", self.supplier, fieldname, self.get(fieldname))
@frappe.whitelist()
def get_timeline_data(doctype: str, name: str):
# Get a list of all the associated scorecards
out = {}
def get_timeline_data(doctype: str, name: str) -> dict[float, float]:
timeline_data = {}
scorecards = frappe.get_all(
@@ -164,8 +160,7 @@ def get_timeline_data(doctype: str, name: str):
for single_date in daterange(sc.start_date, sc.end_date):
timeline_data[time.mktime(single_date.timetuple())] = sc.total_score
out["timeline_data"] = timeline_data
return out
return timeline_data
def daterange(start_date, end_date):

View File

@@ -6,6 +6,5 @@ def get_data():
"heatmap": True,
"heatmap_message": _("This covers all scorecards tied to this Setup"),
"fieldname": "supplier",
"method": "erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.get_timeline_data",
"transactions": [{"label": _("Scorecards"), "items": ["Supplier Scorecard Period"]}],
}

View File

@@ -9,6 +9,7 @@ from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import (
get_scorecard_date,
make_all_scorecards,
)
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard_dashboard import get_data
from erpnext.tests.utils import ERPNextTestSuite
@@ -89,6 +90,30 @@ class TestSupplierScorecard(ERPNextTestSuite):
self.assertGreater(created, 0)
self.assertEqual(make_all_scorecards(doc.name), 0)
def test_dashboard_endpoint_returns_connection_count_and_heatmap(self):
supplier = create_test_supplier("_Test Supplier SC Dashboard")
frappe.db.set_value("Supplier", supplier, "creation", add_days(nowdate(), -75))
frappe.delete_doc_if_exists("Supplier Scorecard", supplier)
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.name = supplier
doc.insert()
endpoint = get_data().get("method") or "frappe.desk.notifications.get_open_count"
dashboard = frappe.get_attr(endpoint)("Supplier Scorecard", doc.name)
counts = {link["doctype"]: link["count"] for link in dashboard["count"]["external_links_found"]}
periods = frappe.db.count("Supplier Scorecard Period", {"supplier": supplier})
self.assertGreater(periods, 0)
self.assertEqual(counts["Supplier Scorecard Period"], periods)
timeline_data = dashboard["timeline_data"]
self.assertTrue(timeline_data)
for timestamp, score in timeline_data.items():
self.assertIsInstance(timestamp, int | float)
self.assertIsInstance(score, int | float)
def make_supplier_scorecard():
my_doc = frappe.get_doc(valid_scorecard[0])

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,7 @@
"doctype": "Print Format",
"font": "Inter",
"font_size": 13,
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jtLStRVi\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"custom_style\":\"font-weight: bold;\"}],\"width\":53},{\"label\":\"\",\"fields\":[{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"font-weight: bold;\\nborder-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\",\"label_color\":\"#292929\"},{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"}],\"width\":44}],\"show_label\":\"hide\",\"field_orientation\":\"left-right\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":21},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":13,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":14,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":10,\"table_header_bg\":\"#f3f3f3\",\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_LeiIYjph\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jtLStRVi\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"custom_style\":\"font-weight: bold;\"}],\"width\":53},{\"label\":\"\",\"fields\":[{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"font-weight: bold;\\nborder-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\",\"label_color\":\"#292929\"},{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"}],\"width\":44}],\"show_label\":\"hide\",\"field_orientation\":\"left-right\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":21,\"merged_fields\":[{\"fieldname\":\"description\",\"fieldtype\":\"Text Editor\",\"style\":\"secondary\"}]},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":13,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":14,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":10,\"table_header_bg\":\"#f3f3f3\",\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_LeiIYjph\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"idx": 0,
"label_color": "#6b7280",
"line_breaks": 0,
@@ -17,7 +17,7 @@
"margin_left": 8.0,
"margin_right": 8.0,
"margin_top": 10.0,
"modified": "2026-07-24 17:19:05.063875",
"modified": "2026-09-16 11:31:38.723639",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Classic",

View File

@@ -51,7 +51,6 @@ def get_data(filters):
mr_item.item_code.as_("item_code"),
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_(
@@ -78,19 +77,14 @@ def get_data(filters):
def apply_representative_lines(rows):
"""Fill item_name/description/uom from one real Material Request Item line per group.
All three are editable per line, so a request listing the same item twice holds several values
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
value, so the engines pick differently. Take the first line by idx.
"""
"""Fill the line-level columns from one real Material Request Item line: the first by idx."""
material_requests = list({row.material_request for row in rows})
representative = {}
if material_requests:
for line in frappe.get_all(
"Material Request Item",
filters={"parent": ("in", material_requests), "docstatus": 1},
fields=["parent", "item_code", "item_name", "description", "uom"],
fields=["parent", "item_code", "item_name", "description", "uom", "stock_uom"],
order_by="idx",
):
representative.setdefault((line.parent, line.item_code), line)
@@ -100,6 +94,7 @@ def apply_representative_lines(rows):
row.item_name = line.item_name if line else None
row.description = line.description if line else None
row.uom = line.uom if line else ""
row.stock_uom = line.stock_uom if line else ""
def get_conditions(filters, query, mr, mr_item):

View File

@@ -74,6 +74,54 @@ class TestRequestedItemsToOrderAndReceive(ERPNextTestSuite):
self.assertEqual(len(data), 1)
self.assertEqual(getdate(data[0].required_date), getdate(add_days(today(), 1)))
def test_uom_pair_comes_from_one_line(self):
"""uom and stock_uom describe a line, so the reported pair must be one that was posted.
A request can list the same item twice in different units. Sourcing each column separately
can report one line's uom beside another's stock_uom -- a pair belonging to neither.
"""
create_item("Test MR Report Uom Item")
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])
mr.transaction_date = today()
mr.schedule_date = add_days(today(), 5)
mr.set("items", mr.items[:1])
row = mr.items[0]
row.item_code = "Test MR Report Uom Item"
row.item_name = "Test MR Report Uom Item"
row.description = "Test MR Report Uom Item"
row.uom = "Nos"
row.schedule_date = mr.schedule_date
mr.append(
"items",
{
"item_code": "Test MR Report Uom Item",
"item_name": "Test MR Report Uom Item",
"description": "Test MR Report Uom Item",
"uom": "Nos",
"qty": row.qty,
"warehouse": row.warehouse,
"schedule_date": mr.schedule_date,
},
)
mr.submit()
# cross the two picks: the line holding the higher uom holds the lower stock_uom, so an
# independently aggregated pair cannot belong to either line
for line, uom, stock_uom in ((mr.items[0], "Nos", "Box"), (mr.items[1], "Box", "Nos")):
frappe.db.set_value(
"Material Request Item",
line.name,
{"uom": uom, "stock_uom": stock_uom},
update_modified=False,
)
posted = {("Nos", "Box"), ("Box", "Nos")}
data = get_data(self.filters.update({"item_code": "Test MR Report Uom Item"}))
self.assertEqual(len(data), 1)
self.assertIn((data[0].uom, data[0].stock_uom), posted)
self.assertEqual((data[0].uom, data[0].stock_uom), ("Nos", "Box"), "must be the first line by idx")
def setup_material_request(self, order=False, receive=False, days=0):
po = None
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])

View File

@@ -0,0 +1,139 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
import frappe.permissions
from erpnext.buying.utils import get_linked_material_requests
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.tests.utils import ERPNextTestSuite
def create_user_with_roles(email, *roles):
if frappe.db.exists("User", email):
user = frappe.get_doc("User", email)
else:
user = frappe.new_doc("User")
user.email = email
user.first_name = email.split("@", 1)[0]
user.insert(ignore_permissions=True)
user.set("roles", [])
for role in roles:
user.append("roles", {"role": role})
user.save(ignore_permissions=True)
# a user left without roles is downgraded to a Website User on save
frappe.db.set_value("User", email, "user_type", "System User")
return user
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
def setUp(self):
self.material_request = make_material_request(item_code="_Test Item")
def test_permitted_role_can_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_populated_result_is_a_flat_list_of_rows(self):
"""Both callers iterate the response directly, so it has to stay a flat list of rows
rather than a list of lists."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIsInstance(rows, list)
self.assertTrue(rows)
for row in rows:
self.assertNotIsInstance(row, list | tuple)
self.assertIsInstance(row, dict)
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
self.assertIn(fieldname, row)
def test_empty_result_is_a_flat_empty_list(self):
item_without_request = make_item("_Test Item Without Material Request").name
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests([item_without_request])
self.assertEqual(rows, [])
def test_a_single_item_code_is_treated_as_one_code(self):
"""A lone code must be read as one item code, not iterated character by character."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(json.dumps("_Test Item"))
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_items_that_are_not_item_codes_are_rejected(self):
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
so these are the malformed inputs that reach the method."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
bad_inputs = (
"not json at all",
[{"item_code": "_Test Item"}],
[["_Test Item"]],
[None],
)
with self.set_user("test_buying_purchase_user@example.com"):
for bad_items in bad_inputs:
with self.subTest(items=bad_items):
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
both of which call this method, so it must hold Material Request read as well."""
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
with self.set_user("test_buying_mfg_manager@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
with self.set_user("test_buying_sales_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
`select` is enough to list names, so the permitted set must be resolved through a
filter on the child table, which requires `read`."""
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
with self.set_user("test_buying_delivery_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_results_are_restricted_by_user_permissions(self):
other_company_request = make_material_request(
item_code="_Test Item",
company="_Test Company 1",
warehouse="_Test Warehouse 2 - _TC1",
cost_center="Main - _TC1",
)
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
try:
with self.set_user(user.name):
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
finally:
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
self.assertIn(self.material_request.name, mr_names)
self.assertNotIn(other_company_request.name, mr_names)

View File

@@ -43,7 +43,9 @@ def update_last_purchase_rate(doc, is_submit) -> None:
frappe.throw(_("UOM Conversion factor is required in row {0}").format(d.idx))
# update last purchsae rate
frappe.db.set_value("Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate))
frappe.db.set_value(
"Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate), update_modified=False
)
def validate_for_items(doc) -> None:
@@ -129,7 +131,33 @@ def get_linked_material_requests(items: str | list):
Retrieve Material Requests linked to a list of items.
"""
items = frappe.parse_json(items)
try:
items = frappe.parse_json(items)
except (TypeError, ValueError):
frappe.throw(_("Items must be a list of Item codes"))
if isinstance(items, str):
items = [items]
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
frappe.throw(_("Items must be a list of Item codes"))
permitted_material_requests = frappe.get_list(
"Material Request",
filters=[
["material_request_type", "=", "Purchase"],
["docstatus", "=", 1],
["status", "!=", "Stopped"],
["per_ordered", "<", 99.99],
["Material Request Item", "item_code", "in", items],
],
pluck="name",
distinct=True,
)
if not permitted_material_requests:
return []
mr_list = []
mr = frappe.qb.DocType("Material Request")
@@ -146,6 +174,7 @@ def get_linked_material_requests(items: str | list):
mr_item.item_code,
mr_item.name.as_("mr_item"),
)
.where(mr.name.isin(permitted_material_requests))
.where(mr_item.item_code == item)
.where(mr.material_request_type == "Purchase")
.where(mr.per_ordered < 99.99)

View File

@@ -258,6 +258,8 @@ class AccountsController(TransactionBase):
if self.get("_action") and self._action != "update_after_submit":
self.set_missing_values(for_validate=True)
self.validate_price_list()
if self.get("_action") == "submit":
self.remove_bundle_for_non_stock_invoices()
@@ -346,6 +348,28 @@ class AccountsController(TransactionBase):
self.set_default_letter_head()
self.validate_company_in_accounting_dimension()
def validate_price_list(self):
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
price_list = self.get(price_list_field)
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
return
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
if (
self.get("is_return")
and self.get("return_against")
and price_list
== frappe.db.get_value(
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
)
):
return
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
def set_default_letter_head(self):
if hasattr(self, "letter_head") and not self.letter_head:
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
@@ -1188,32 +1212,44 @@ class AccountsController(TransactionBase):
self.unlink_ref_doc_from_po()
def unlink_ref_doc_from_po(self):
so_items = []
for item in self.items:
so_items.append(item.name)
so_items = [item.name for item in self.items]
filters = {
"sales_order": self.name,
"sales_order_item": ["in", so_items],
"docstatus": ["<", 2],
}
linked_po = list(
set(
frappe.get_all(
"Purchase Order Item",
filters={
"sales_order": self.name,
"sales_order_item": ["in", so_items],
"docstatus": ["<", 2],
},
pluck="parent",
)
linked_po_items = frappe.get_all(
"Purchase Order Item", filters=filters, fields=["parent", "sales_order_item"]
)
if not linked_po_items:
return
frappe.db.set_value("Purchase Order Item", filters, {"sales_order": None, "sales_order_item": None})
self.update_ordered_qty_in_items({item.sales_order_item for item in linked_po_items})
linked_po = sorted({item.parent for item in linked_po_items})
frappe.msgprint(_("Purchase Orders {0} are unlinked").format("\n".join(linked_po)))
def update_ordered_qty_in_items(self, so_items: set[str]):
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
ordered_qty = dict(
frappe.qb.from_(purchase_order_item)
.select(purchase_order_item.sales_order_item, Sum(purchase_order_item.stock_qty))
.where(
purchase_order_item.sales_order_item.isin(list(so_items))
& (purchase_order_item.docstatus == 1)
)
.groupby(purchase_order_item.sales_order_item)
.run()
)
if linked_po:
frappe.db.set_value(
"Purchase Order Item",
{"sales_order": self.name, "sales_order_item": ["in", so_items], "docstatus": ["<", 2]},
{"sales_order": None, "sales_order_item": None},
)
items_by_ordered_qty = defaultdict(list)
for so_item in so_items:
items_by_ordered_qty[flt(ordered_qty.get(so_item))].append(so_item)
frappe.msgprint(_("Purchase Orders {0} are unlinked").format("\n".join(linked_po)))
for qty, items in items_by_ordered_qty.items():
frappe.db.set_value("Sales Order Item", {"name": ["in", items]}, "ordered_qty", qty)
def get_company_default(self, fieldname, ignore_validation=False):
from erpnext.accounts.utils import get_company_default

View File

@@ -23,7 +23,7 @@ from erpnext.stock.get_item_details import (
get_conversion_factor,
get_item_defaults,
)
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
class QtyMismatchError(ValidationError):
@@ -180,7 +180,7 @@ class BuyingController(SubcontractingController):
for row in self.items:
if row.rate <= 0:
# override the rate with valuation rate
row.rate = get_incoming_rate(
row.rate = _get_incoming_rate(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
@@ -664,7 +664,7 @@ class BuyingController(SubcontractingController):
if not posting_time:
posting_time = nowtime()
outgoing_rate = get_incoming_rate(
outgoing_rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.get("from_warehouse"),

View File

@@ -40,6 +40,11 @@ def get_variant(
:param item: Template Item
:param args: A dictionary with "Attribute" as key and "Attribute Value" as value
"""
# The client callers are the Item form (item.js:1144, 1483), so the template Item is the boundary
# and `read` is loser-free: the roles that cannot read Item cannot open that form either. The two
# server-side callers (item.py:1083 on Item save, item_variant.py:393) already hold the template.
frappe.has_permission("Item", doc=template, throw=True)
item_template = frappe.get_doc("Item", template)
if item_template.variant_based_on == "Manufacturer" and manufacturer:
@@ -318,6 +323,12 @@ def find_variant(template, args, variant_item_code=None):
@frappe.whitelist()
def create_variant(item: str, args: dict | str, use_template_image: bool = False):
# Same right its sibling enqueue_multiple_variant_creation already requires — this builds an
# Item the caller is about to insert (item.js:1511) — plus record-level read on the template
# it copies from.
frappe.has_permission("Item", ptype="create", throw=True)
frappe.has_permission("Item", doc=item, throw=True)
use_template_image = frappe.parse_json(use_template_image)
args = frappe.parse_json(args)
@@ -342,7 +353,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False
return variant
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_image: bool = False):
use_template_image = frappe.parse_json(use_template_image)
# There can be innumerable attribute combinations, enqueue
@@ -539,6 +550,10 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
@frappe.whitelist()
def create_variant_doc_for_quick_entry(template: str, args: dict | str):
# Delegates to get_variant and create_variant below, which carry their own checks; this one
# fails fast rather than relying on that delegation.
frappe.has_permission("Item", doc=template, throw=True)
variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
args = frappe.parse_json(args)
if variant_based_on == "Manufacturer":

View File

@@ -482,49 +482,71 @@ def get_project_name(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
meta = frappe.get_meta(doctype)
list_filters = [["status", "not in", ["Completed", "Cancelled", "On hold"]]]
if filters:
if filters.get("customer"):
qb_filter_and_conditions.append(
(proj.customer == filters.get("customer")) | (proj.customer.isnull()) | (proj.customer == "")
)
# an `in` containing "" renders as `ifnull(customer,'') in (...)`: this customer, or none
list_filters.append(["customer", "in", [filters.get("customer"), ""]])
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)
fields = get_fields(doctype, ["name", "project_name"])
for x in fields:
q = q.select(proj[x])
list_filters.append(["company", "=", filters.get("company")])
# don't consider 'customer' and 'status' fields for pattern search, as they must be exactly matched
# permlevel fields go too: get_list refuses to filter on one, which would fail the whole call
searchfields = [
x for x in frappe.get_meta(doctype).get_search_fields() if x not in ["customer", "status"]
x
for x in meta.get_search_fields()
if x not in ["customer", "status"] and not (meta.get_field(x) and meta.get_field(x).permlevel)
]
# pattern search
if txt:
for x in searchfields:
qb_filter_or_conditions.append(proj[x].like(f"%{txt}%"))
fields = get_fields(doctype, ["name", "project_name"])
q = q.where(Criterion.all(qb_filter_and_conditions)).where(Criterion.any(qb_filter_or_conditions))
# ordering
if txt:
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(
Case()
.when(
Locate(Lower(txt), Lower(proj.project_name)) > 0,
Locate(Lower(txt), Lower(proj.project_name)),
)
.else_(99999)
# get_list applies the doctype check and the caller's record-level conditions
if not txt:
# no search term means no relevance ordering, so the whole query is expressible here and
# stays paginated in SQL rather than materialising every permitted name
return frappe.get_list(
"Project",
filters=list_filters,
fields=fields,
order_by="idx desc, name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
# with a search term, resolve the (already LIKE-narrowed) permitted names and rank them below:
# the relevance ordering is a CASE expression, which `order_by` rejects
permitted = frappe.get_list(
"Project",
filters=list_filters,
or_filters=[[x, "like", f"%{txt}%"] for x in searchfields],
pluck="name",
order_by="",
limit_page_length=0,
)
if not permitted:
return []
q = (
frappe.qb.from_(proj)
.select(*[proj[fieldname] for fieldname in fields])
.where(proj.name.isin(permitted))
)
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(
Case()
.when(
Locate(Lower(txt), Lower(proj.project_name)) > 0,
Locate(Lower(txt), Lower(proj.project_name)),
)
.else_(99999)
)
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
if page_len:
@@ -798,28 +820,33 @@ def get_account_list(
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
bo = frappe.qb.DocType("Blanket Order")
bo_item = frappe.qb.DocType("Blanket Order Item")
bo_filters = [
["docstatus", "=", 1],
["blanket_order_type", "=", filters.get("blanket_order_type")],
["company", "=", filters.get("company")],
]
query = (
frappe.qb.from_(bo)
.from_(bo_item)
.select(bo.name)
.distinct()
.select(bo.blanket_order_type, bo.to_date)
.where(
(bo_item.parent == bo.name)
& (bo_item.item_code == filters.get("item"))
& (bo.blanket_order_type == filters.get("blanket_order_type"))
& (bo.company == filters.get("company"))
& (bo.docstatus == 1)
if frappe.has_permission("Blanket Order", "read"):
bo_filters.append(["Blanket Order Item", "item_code", "=", filters.get("item")])
else:
parents = frappe.get_all(
"Blanket Order Item",
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order"},
pluck="parent",
distinct=True,
)
)
bo_filters.append(["name", "in", parents or [""]])
if currency := filters.get("currency"):
query = query.where(bo.currency == currency)
bo_filters.append(["currency", "=", currency])
return query.run()
return frappe.get_list(
"Blanket Order",
filters=bo_filters,
fields=["name", "blanket_order_type", "to_date"],
group_by="name",
as_list=True,
)
@frappe.whitelist()
@@ -1015,21 +1042,22 @@ def get_doctype_wise_filters(filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(batch)
.select(batch.batch_id)
.where(
(batch.disabled == 0)
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
& batch.name.like(f"%{txt}%")
)
)
# get_list applies the select check and the caller's record-level conditions together
batch_filters = [["disabled", "=", 0], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item"):
query = query.where(batch.item == filters.get("item"))
batch_filters.append(["item", "=", filters.get("item")])
return query.orderby(batch.batch_id).limit(page_len).offset(start).run()
return frappe.get_list(
"Batch",
filters=batch_filters,
or_filters=[["expiry_date", "is", "not set"], ["expiry_date", ">=", today()]],
fields=["batch_id"],
order_by="batch_id",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@@ -1056,41 +1084,71 @@ def item_manufacturer_query(
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_receipts(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
pr = frappe.qb.DocType("Purchase Receipt")
pr_item = frappe.qb.DocType("Purchase Receipt Item")
query = (
frappe.qb.from_(pr)
.inner_join(pr_item)
.on(pr_item.parent == pr.name)
.select(pr.name)
.distinct() # one row per receipt, not per matching item line
.where((pr.docstatus == 1) & pr.name.like(f"%{txt}%"))
)
pr_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item_code"):
query = query.where(pr_item.item_code == filters.get("item_code"))
if frappe.has_permission("Purchase Receipt", "read"):
# one indexed join, deduped by group_by below
pr_filters.append(["Purchase Receipt Item", "item_code", "=", filters.get("item_code")])
else:
# a select-only caller may use this picker but may not filter on a child table, so resolve
# the parents separately rather than losing the filter to a PermissionError
parents = frappe.get_all(
"Purchase Receipt Item",
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Receipt"},
pluck="parent",
distinct=True,
)
pr_filters.append(["name", "in", parents or [""]])
return query.orderby(pr.name).limit(page_len).offset(start).run()
# get_list applies the select check and the caller's record-level conditions together.
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
# from a distinct query on Postgres
return frappe.get_list(
"Purchase Receipt",
filters=pr_filters,
fields=["name"],
group_by="name",
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_invoices(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
pi = frappe.qb.DocType("Purchase Invoice")
pi_item = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi_item.parent == pi.name)
.select(pi.name)
.distinct() # one row per invoice, not per matching item line
.where((pi.docstatus == 1) & pi.name.like(f"%{txt}%"))
)
pi_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item_code"):
query = query.where(pi_item.item_code == filters.get("item_code"))
if frappe.has_permission("Purchase Invoice", "read"):
# one indexed join, deduped by group_by below
pi_filters.append(["Purchase Invoice Item", "item_code", "=", filters.get("item_code")])
else:
# a select-only caller may use this picker but may not filter on a child table, so resolve
# the parents separately rather than losing the filter to a PermissionError
parents = frappe.get_all(
"Purchase Invoice Item",
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Invoice"},
pluck="parent",
distinct=True,
)
pi_filters.append(["name", "in", parents or [""]])
return query.orderby(pi.name).limit(page_len).offset(start).run()
# get_list applies the select check and the caller's record-level conditions together.
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
# from a distinct query on Postgres
return frappe.get_list(
"Purchase Invoice",
filters=pi_filters,
fields=["name"],
group_by="name",
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@@ -1177,9 +1235,30 @@ def get_payment_terms_for_references(
):
terms = []
if filters:
reference = filters.get("reference")
if not reference:
return terms
# only a plain name names one document: a filter operator (["like", "%"], ["!=", ""]) would
# widen this past the document the caller named, and past the one being authorised below
if not isinstance(reference, str):
frappe.throw(_("Invalid reference"), frappe.PermissionError)
# Payment Schedule is a child table and carries no permissions of its own, so the
# document the schedule belongs to is what decides access to these rows
# prefer the caller's own reference type; the lookup below cannot tell two parents of
# different types apart when they share a name
parenttype = filters.get("reference_doctype") or frappe.db.get_value(
"Payment Schedule", {"parent": reference}, "parenttype"
)
if not parenttype:
return terms
frappe.has_permission(parenttype, doc=reference, throw=True)
terms = frappe.db.get_all(
"Payment Schedule",
filters={"parent": filters.get("reference")},
filters={"parent": reference, "parenttype": parenttype},
fields=["payment_term"],
limit=page_len,
as_list=1,
@@ -1192,6 +1271,31 @@ def get_payment_terms_for_references(
def get_filtered_child_rows(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
):
parent = filters.get("parent") if filters else None
# a plain name, never a filter operator: ["like", "%"] here would span parents, and only one
# of them would be the document authorised below
if not parent or not isinstance(parent, str):
frappe.throw(_("Parent document is required to search child rows"), frappe.PermissionError)
# `doctype` is caller supplied, so it has to be a child table before it is worth checking:
# any other doctype would put the caller's filters on a table this query never meant to read
if not frappe.get_meta(doctype).istable:
frappe.throw(_("{0} is not a child table").format(doctype), frappe.PermissionError)
# child tables carry no permissions of their own, so the document the rows hang off is what
# decides access. Read the parent type off the rows rather than off `filters`, so that the
# document being authorised is always the one being returned.
parenttype = frappe.db.get_value(doctype, {"parent": parent}, "parenttype")
if not parenttype or not frappe.db.exists(parenttype, parent):
return []
frappe.has_permission(doctype, parent_doctype=parenttype, throw=True)
# and on the parent record itself, so that User Permissions still apply
frappe.has_permission(parenttype, doc=parent, throw=True)
table = frappe.qb.DocType(doctype)
query = (
frappe.get_query(table, filters=filters)
@@ -1217,7 +1321,11 @@ def get_filtered_child_rows(
@frappe.validate_and_sanitize_search_inputs
def get_item_uom_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
if frappe.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
query_filters = {"parent": filters.get("item_code")}
item_code = filters.get("item_code")
if not item_code or not frappe.get_list("Item", filters=[["name", "=", item_code]], pluck="name"):
return []
query_filters = {"parent": item_code, "parenttype": "Item"}
if txt:
query_filters["uom"] = ["like", f"%{txt}%"]
@@ -1232,7 +1340,7 @@ def get_item_uom_query(doctype: str, txt: str, searchfield: str, start: int, pag
as_list=1,
)
return frappe.get_all(
return frappe.get_list(
"UOM",
filters={"name": ["like", f"%{txt}%"], "enabled": 1},
fields=["name"],

View File

@@ -12,7 +12,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method, getdate
class StockOverReturnError(frappe.ValidationError):
@@ -194,7 +194,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
fields = ["qty"]
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]:
tracks_accepted_rejected_split = doc.doctype in (
"Purchase Receipt",
"Subcontracting Receipt",
) or (doc.doctype == "Purchase Invoice" and doc.update_stock)
if tracks_accepted_rejected_split:
if not args.get("return_qty_from_rejected_warehouse"):
fields.extend(["received_qty", "rejected_qty"])
else:
@@ -816,7 +821,7 @@ def get_rate_for_return(
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
if rate is None and sle:
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": sle.item_code,
"warehouse": sle.warehouse,
@@ -1304,14 +1309,38 @@ def get_available_serial_nos(serial_nos, warehouse):
)
# the only doctypes these endpoints are called for; both reach get_value()/get_all() as the doctype itself
RETURNABLE_INVOICE_DOCTYPES = ("Sales Invoice", "POS Invoice")
@frappe.whitelist()
def get_payment_data(invoice: str):
# `invoice` may be either a Sales Invoice or a POS Invoice — both share the Sales Invoice
# Payment child table — so resolve which one it is before authorising rather than guessing.
parenttype = frappe.db.get_value("Sales Invoice Payment", {"parent": invoice}, "parenttype")
if not parenttype:
return []
if parenttype not in RETURNABLE_INVOICE_DOCTYPES:
frappe.throw(_("Invalid document type"), frappe.PermissionError)
frappe.has_permission(parenttype, doc=invoice, throw=True)
payment = frappe.db.get_all("Sales Invoice Payment", {"parent": invoice}, ["mode_of_payment", "amount"])
return payment
def validate_returnable_invoice(doctype: str, invoice: str) -> None:
if doctype not in RETURNABLE_INVOICE_DOCTYPES:
frappe.throw(_("Invalid document type"), frappe.PermissionError)
frappe.has_permission(doctype, doc=invoice, throw=True)
@frappe.whitelist()
def get_invoice_item_returned_qty(doctype: str, invoice: str, customer: str, item_row_name: str):
validate_returnable_invoice(doctype, invoice)
is_return, docstatus = frappe.db.get_value(doctype, invoice, ["is_return", "docstatus"])
if not is_return and docstatus == 1:
return get_returned_qty_map_for_row(invoice, customer, item_row_name, doctype)
@@ -1319,6 +1348,8 @@ def get_invoice_item_returned_qty(doctype: str, invoice: str, customer: str, ite
@frappe.whitelist()
def is_invoice_returnable(doctype: str, invoice: str):
validate_returnable_invoice(doctype, invoice)
is_return, docstatus, customer = frappe.db.get_value(
doctype, invoice, ["is_return", "docstatus", "customer"]
)

View File

@@ -13,7 +13,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
class SellingController(StockController):
@@ -588,7 +588,7 @@ class SellingController(StockController):
and self.get("is_return")
)
):
d.incoming_rate = get_incoming_rate(
d.incoming_rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.warehouse,

View File

@@ -21,7 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
class SubcontractingController(StockController):
@@ -89,7 +89,7 @@ class SubcontractingController(StockController):
}
)
rate = get_incoming_rate(kwargs)
rate = _get_incoming_rate(kwargs)
precision = frappe.get_precision("Subcontracting Receipt Supplied Item", "rate")
if flt(rate, precision) != flt(row.rate, precision):
row.rate = rate
@@ -844,7 +844,7 @@ class SubcontractingController(StockController):
args["batch_no"] = rm_obj.batch_no
args["serial_no"] = rm_obj.serial_no
rm_obj.rate = get_incoming_rate(args)
rm_obj.rate = _get_incoming_rate(args)
def __set_batch_nos(self, bom_item, item_row, rm_obj, qty):
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field))

View File

@@ -1161,7 +1161,11 @@ def get_fg_reference_names(
"Subcontracting Inward Order Item",
limit_start=start,
limit_page_length=page_len,
filters={"parent": filters.get("parent"), "item_code": ("like", f"%{txt}%"), "docstatus": 1},
filters={"parent": filters.get("parent"), "docstatus": 1},
or_filters=[
["name", "like", f"%{txt}%"],
["item_code", "like", f"%{txt}%"],
],
fields=["name", "item_code", "delivery_warehouse"],
as_list=True,
order_by="idx",

View File

@@ -2410,4 +2410,10 @@ class TestAccountsController(ERPNextTestSuite):
si.set_posting_time = 1
si.posting_date = "2026-01-01"
si.save()
self.assertEqual(si.name, "SI-01-2026-00001")
si = create_sales_invoice(do_not_save=True)
si.set_posting_time = 1
si.posting_date = "2026-01-15"
si.save()
self.assertEqual(si.name, "SI-01-2026-00002")

View File

@@ -104,3 +104,96 @@ class TestReactivity(ERPNextTestSuite):
self.assertEqual(sales_invoice.items[0].uom, "Kg")
self.assertEqual(sales_invoice.items[0].conversion_factor, 1)
self.assertEqual(sales_invoice.items[0].stock_qty, sales_invoice.items[0].qty)
def add_optional_items_table(self):
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
create_custom_fields(
{
"Sales Order": [
{
"fieldname": "optional_items",
"label": "Optional Items",
"fieldtype": "Table",
"options": "Sales Order Item",
"insert_after": "items",
}
]
}
)
self.addCleanup(frappe.clear_cache, doctype="Sales Order")
self.addCleanup(frappe.delete_doc, "Custom Field", "Sales Order-optional_items")
def make_sales_order_with_optional_items(self, item_code, optional_item_codes):
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
self.add_optional_items_table()
sales_order = make_sales_order(item_code=item_code, uom="Kg", rate=500, do_not_save=True)
for optional_item_code in optional_item_codes:
sales_order.append("optional_items", {"item_code": optional_item_code, "qty": 1})
return sales_order
def test_item_selection_updates_the_row_in_its_own_child_table(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
optional_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Nos"})
sales_order = self.make_sales_order_with_optional_items(item.name, [item.name, optional_item.name])
standard_row = sales_order.items[0]
row_state = (standard_row.item_code, standard_row.uom, standard_row.rate)
edited_row = sales_order.optional_items[1]
sales_order.process_item_selection(
edited_row.idx, reset_item_details=True, parentfield="optional_items"
)
self.assertEqual(edited_row.item_name, optional_item.item_name)
self.assertEqual(edited_row.uom, "Nos")
self.assertEqual((standard_row.item_code, standard_row.uom, standard_row.rate), row_state)
def test_item_selection_ignores_a_row_that_is_gone(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
sales_order = self.make_sales_order_with_optional_items(item.name, [])
sales_order.process_item_selection(len(sales_order.items) + 1)
self.assertEqual(len(sales_order.items), 1)
def test_item_selection_rejects_a_field_that_is_not_a_child_table(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
sales_order = self.make_sales_order_with_optional_items(item.name, [])
self.assertRaises(
frappe.ValidationError, sales_order.process_item_selection, 1, parentfield="company"
)
def test_free_item_is_added_to_the_table_that_earned_it(self):
from erpnext.accounts.doctype.pricing_rule.test_pricing_rule import make_pricing_rule
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
optional_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
free_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Kg"})
make_pricing_rule(
title=f"_Test Free Item Rule {optional_item.name}",
selling=1,
item_code=optional_item.name,
price_or_product_discount="Product",
free_item=free_item.name,
free_qty=1,
)
sales_order = self.make_sales_order_with_optional_items(item.name, [optional_item.name])
sales_order.process_item_selection(sales_order.optional_items[0].idx, parentfield="optional_items")
self.assertEqual([row.item_code for row in sales_order.items], [item.name])
self.assertEqual(
[row.item_code for row in sales_order.optional_items],
[optional_item.name, free_item.name],
)

View File

@@ -400,13 +400,15 @@ def quotation_party_name_expr():
def quotation_territory_expr():
"""Only Customer and Lead carry a territory; other party types have none."""
"""Territory from the party master. CRM Deal has none here: it ships with the CRM app."""
return (
"case "
"when t1.quotation_to = 'Customer' then "
"(select c.territory from `tabCustomer` c where c.name = t1.party_name) "
"when t1.quotation_to = 'Lead' then "
"(select l.territory from `tabLead` l where l.name = t1.party_name) "
"when t1.quotation_to = 'Prospect' then "
"(select p.territory from `tabProspect` p where p.name = t1.party_name) "
"end"
)

View File

@@ -114,6 +114,16 @@ def make_quotation(source_name: str, target_doc: str | dict | Document | None =
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""
# `communication` is caller supplied and nothing here checked it. Communication grants read to
# `All` only for the owner (if_owner) and carries a has_permission hook, so doc= is what decides
# access; the desk button only appears on an email the caller already has open.
frappe.has_permission("Communication", doc=communication, throw=True)
# both paths below end in a Lead. The insert path checks `create` on its own, but the path that
# reuses an existing Lead required nothing, so it returned a Lead's name and linked the email
# for callers with no access to Leads at all.
frappe.has_permission("Lead", ptype="create", throw=True)
doc = frappe.get_doc("Communication", communication)
lead_name = None
if doc.sender:

View File

@@ -130,8 +130,15 @@ def make_opportunity_from_communication(
):
from erpnext.crm.doctype.lead.mapper import make_lead_from_communication
# `communication` is caller supplied and nothing checked it. Communication grants read to `All`
# only for the owner (if_owner) and carries a has_permission hook, so doc= is what decides
# access; the desk button only appears on an email the caller already has open.
frappe.has_permission("Communication", doc=communication, throw=True)
doc = frappe.get_doc("Communication", communication)
# make_lead_from_communication() carries its own check, but it is skipped entirely when the
# email already references a Lead, so this cannot rely on it.
lead = doc.reference_name if doc.reference_doctype == "Lead" else None
if not lead:
lead = make_lead_from_communication(communication, ignore_communication_links=True)

View File

@@ -150,6 +150,11 @@ def link_open_events(ref_doctype, ref_docname, doc):
@frappe.whitelist()
def get_open_activities(ref_doctype: str, ref_docname: str):
# both arguments are caller supplied and nothing below checked them: the ToDo and Event rows are
# read with get_all, so the referenced document is what decides who may see its activities.
# doc= applies User Permissions; the desk only asks this for a form the caller has open.
frappe.has_permission(ref_doctype, doc=ref_docname, throw=True)
tasks = get_open_todos(ref_doctype, ref_docname)
events = get_open_events(ref_doctype, ref_docname)
tasks_history = get_closed_todos(ref_doctype, ref_docname)

View File

@@ -20,8 +20,11 @@ class CodeListSelectionMismatchError(Exception):
pass
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def import_genericode():
# check before save(), which only runs after the XML is fetched and parsed; denies exactly who save() would, sooner
frappe.has_permission("Code List", "create", throw=True)
try:
content, file_name = get_uploaded_genericode_file()
@@ -149,7 +152,7 @@ def parse_genericode_content(content: bytes):
return etree.fromstring(content, parser=parser)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def process_genericode_import(
code_list_name: str,
file_name: str,
@@ -160,6 +163,11 @@ def process_genericode_import(
):
from erpnext.edi.doctype.common_code.common_code import import_genericode
# Same reasoning as above: common_code.save() enforces this per document, but only after the
# file has been read and its XML parsed and queried.
frappe.has_permission("Common Code", "create", throw=True)
frappe.has_permission("Code List", doc=code_list_name, throw=True)
column_map = {"code": code_column, "title": title_column, "description": description_column}
return import_genericode(

View File

@@ -37,8 +37,12 @@ class PlaidSettings(Document):
return plaid.get_link_token()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def get_plaid_configuration():
# Returns plaid_env and a freshly minted Plaid link_token. Plaid Settings is a System-Manager-only
# single doctype and every caller reaches this from its own form, so that is the boundary.
frappe.has_permission("Plaid Settings", throw=True)
if frappe.db.get_single_value("Plaid Settings", "enabled"):
plaid_settings = frappe.get_single("Plaid Settings")
return {
@@ -52,6 +56,8 @@ def get_plaid_configuration():
@frappe.whitelist(methods=["POST"])
def add_institution(token: str, response: str | dict):
frappe.has_permission("Plaid Settings", throw=True)
response = frappe.parse_json(response)
plaid = PlaidConnector()
@@ -81,6 +87,8 @@ def add_institution(token: str, response: str | dict):
@frappe.whitelist(methods=["POST"])
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
frappe.has_permission("Plaid Settings", throw=True)
response = frappe.parse_json(response)
bank = frappe.parse_json(bank)
result = []
@@ -328,8 +336,10 @@ def automatic_synchronization():
enqueue_synchronization()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def enqueue_synchronization():
frappe.has_permission("Plaid Settings", throw=True)
plaid_accounts = frappe.get_all(
"Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]
)
@@ -342,8 +352,12 @@ def enqueue_synchronization():
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def get_link_token_for_update(access_token: str):
# `access_token` is caller-supplied and is used to mint a link token at Plaid, so this creates
# state at the provider even though it writes nothing here.
frappe.has_permission("Plaid Settings", throw=True)
plaid = PlaidConnector(access_token)
return plaid.get_link_token(update_mode=True)
@@ -362,8 +376,10 @@ def get_company(bank_account_name):
frappe.throw(_("Could not detect the Company for updating Bank Accounts"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_bank_account_ids(response: str | dict):
frappe.has_permission("Plaid Settings", throw=True)
data = frappe.parse_json(response)
institution_name = data["institution"]["name"]
bank = frappe.get_doc("Bank", institution_name).as_dict()

Some files were not shown because too many files have changed in this diff Show More