Compare commits

...

51 Commits

Author SHA1 Message Date
Sudharsanan Ashok
73fc4a64ed fix(stock): keep manufactured item rate at zero when inputs are free (#57334)
* fix(stock): keep manufactured item rate at zero when inputs are free

when a finished item is produced from raw materials consumed at zero
valuation, the incoming rate fell back to the item's own valuation
rate (or BOM cost), valuing free inputs as output and inflating the fg
value on every production run.

add has_consumption_basis() to detect when the consumed cost is known
even if it is zero (consumed rows present, or a consumption entry
exists for the work order). when it is, skip the get_valuation_rate and
BOM-cost fallbacks so a real cost of zero is preserved.

* test(stock): cover manufacture rate for zero-valued raw materials

- manufacture from a free input keeps fg basic_rate and sle
  incoming_rate/stock_value_difference at zero even when the fg already
  carries a valuation in the target warehouse
- material consumption on with no consumption entry does not fall back
  to bom/price-list rate for free inputs
- zero-valued consumption entry keeps the manufacture entry's fg rate
  at zero

(cherry picked from commit 73224d3650)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-07-27 18:05:55 +00:00
mergify[bot]
59efe7299a fix(stock): narrow legacy serial ledger lookup by item (backport #57499) (#57506)
fix(stock): narrow legacy serial ledger lookup by item (#57499)

Filter legacy Stock Ledger Entry lookups by item code so the existing
item and warehouse index can reduce rows scanned during serial valuation.

(cherry picked from commit 425191e57e)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-27 22:10:00 +05:30
mergify[bot]
3ea19d8eb1 fix(crm): clarify the reason why an opportunity cannot be declared as lost (backport #57495) (#57498)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 17:01:21 +02:00
ruthra kumar
1f9c4bc933 Merge pull request #57484 from ruthra-kumar/fix_flaky_err_test
fix(test): flaky test in exchange rate revaluation
2026-07-27 17:09:32 +05:30
ruthra kumar
484ff8e349 fix(test): flaky test in exchange rate revaluation
- remove redundant setup on system settings
2026-07-27 16:57:09 +05:30
mergify[bot]
ad9870acc1 fix(crm): align Opportunity status checks with Quotation statuses (backport #57489) (#57491)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 12:22:35 +02:00
Mihir Kandoi
8925585895 Merge pull request #57481 from frappe/mergify/bp/version-16-hotfix/pr-57463
fix(subcontracting): release raw-material reservation when closing a subcontracting order (backport #57463)
2026-07-27 15:01:03 +05:30
Mihir Kandoi
8d511c9b71 Merge pull request #57487 from frappe/mergify/bp/version-16-hotfix/pr-57485
fix: pool batch slot values on every run, not only when negative (backport #57485)
2026-07-27 14:04:17 +05:30
Mihir Kandoi
85925680ac test: assert batch pooling preserves the group total on a repeating rate
(cherry picked from commit 545262c5d4)
2026-07-27 08:24:15 +00:00
Mihir Kandoi
5763378ee1 fix: pool batch slot values on every run, not only when negative
A batch is one valuation pool, so any per-slot value difference within a
batch is stale detail from the report's own age slots, not real valuation.
The rebalance only ran when consumption had already driven a slot negative,
so a batch whose receipts landed at different rates kept a skewed split
across age buckets (one bucket free, another double-priced) while the total
stayed correct.

Drop the negative-slot precondition and always spread a batch's pooled value
over its slots in proportion to qty. Redistribution preserves group totals,
so buckets still sum to Stock Balance; only the split across ages changes.

(cherry picked from commit cedaaa3a00)
2026-07-27 08:24:15 +00:00
Lakshit Jain
c3eac77ee1 Merge pull request #56952 from frappe/mergify/bp/version-16-hotfix/pr-54855
refactor(financial-report): fix row transformation for growth calculations (backport #54855)
2026-07-27 13:31:55 +05:30
Sudharsanan11
008c3b145d test(subcontracting): cover reservation release on closing a subcontracting order
close a partially-received sco with a reserve warehouse and assert the
raw-material reservation is released and projected qty recovers.

(cherry picked from commit e4b8065a69)
2026-07-27 13:01:24 +05:30
Sudharsanan11
ee75fac9d7 fix(subcontracting): release raw-material reservation when closing a subcontracting order
the bin reserved-qty recalc filtered out closed purchase orders but not
closed subcontracting orders, so closing a partially-received sco kept the
reservation for the unreceived qty and left projected qty understated.
apply the same closed-status filter to the subcontracting order path.

(cherry picked from commit db91a79d31)

# Conflicts:
#	erpnext/stock/doctype/bin/bin.py
2026-07-27 13:01:24 +05:30
ruthra kumar
220d005cc2 Merge pull request #57478 from frappe/mergify/bp/version-16-hotfix/pr-57476
refactor: configurable date in reverse ERR journals (backport #57476)
2026-07-27 12:34:16 +05:30
ruthra kumar
ed1a58aa2a ci: debugging 2026-07-27 12:23:33 +05:30
ruthra kumar
2fd1dbd23a refactor(test): manually submit reverse err journal
(cherry picked from commit 1a558ce641)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
2026-07-27 11:53:29 +05:30
ruthra kumar
68dcc96527 refactor: configurable date in reverse ERR journals
(cherry picked from commit 0be33e4132)
2026-07-27 06:11:10 +00:00
Shllokkk
708d889036 Merge pull request #57473 from frappe/mergify/bp/version-16-hotfix/pr-57443
fix: rename misleading filter labels in AR/AP reports (backport #57443)
2026-07-26 20:22:08 +05:30
Shllokkk
20e2728037 chore: resolve patches.txt conflict for backport 2026-07-26 19:21:01 +05:30
MochaMind
2359e1610a chore: update POT file (#57470) 2026-07-26 15:45:55 +02:00
Shllokkk
2e5fc38179 fix: migrate stored AR/AP ageing filter to renamed field
(cherry picked from commit f13cd00494)

# Conflicts:
#	erpnext/patches.txt
2026-07-26 11:47:26 +00:00
Shllokkk
6d856fa632 fix: rename misleading filter labels in AR/AP reports
(cherry picked from commit e99425b7c4)
2026-07-26 11:47:26 +00:00
Shllokkk
1802c75ed6 Merge pull request #57468 from frappe/mergify/bp/version-16-hotfix/pr-57466
fix: recalculate operating cost on hour rate change in routing (backport #57466)
2026-07-26 16:27:11 +05:30
Shllokkk
c182b4085b fix: recalculate operating cost on hour rate change in routing
(cherry picked from commit 598f6f0f4e)
2026-07-26 08:37:06 +00:00
mergify[bot]
a0af717234 fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport #57458) (#57460)
fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (#57458)

(cherry picked from commit 4e8f5de5cb)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-25 08:27:06 +05:30
Jatin3128
d3c5e866f0 feat: block sales invoice submit when customer overdue exceeds threshold (backport #57230, #57298) (#57438)
* feat: block sales invoice submit when customer overdue exceeds threshold (#57230)

* feat: block sales invoice submit when customer overdue exceeds threshold

Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in
Accounts Settings, submitting a Sales Invoice is blocked if the customer's
overdue amount exceeds their threshold, unless the current user holds a
configured bypass role. Modeled on the existing credit limit feature.

- Accounts Settings (Credit Limits tab): enable toggle + bypass role.
- Per-customer threshold on the Customer Credit Limit table, shown only
  when the feature is enabled via a property setter (same mechanism as
  subscription / accounting dimension sections). Table relabeled to
  "Credit & Overdue Limits".
- Overdue is read live from the ledger via get_outstanding_invoices
  (payments already netted), summing Sales Invoices past their due date.
- Enforced in Sales Invoice on_submit, after the credit-limit check;
  returns are exempt.
- validate_credit_limit_on_change no longer trips when a row sets only
  the overdue threshold (credit_limit = 0).

Fixes #52960

* fix: compute overdue amount in company currency and format with fmt_money

get_customer_overdue_amount now sums GL Entry debit - credit grouped per
invoice, which is always booked in company currency, instead of using
get_outstanding_invoices which returns the receivable-account currency.
The threshold is in company currency, so the previous comparison could mix
currencies for customers with a foreign-currency receivable account. This
mirrors how get_customer_outstanding computes the figure for the existing
credit-limit check.

The blocking message now formats both amounts with fmt_money using the
company currency.

Adds a test asserting a 100 USD invoice at a conversion rate of 50 is
counted as 5000 in company currency.

* refactor: drop redundant threshold coercion and dead test cleanup

- Coerce the overdue threshold with flt() once when reading it, instead of
  calling flt() on it at each of the three use sites.
- Remove a no-op set_overdue_billing_threshold() call in the feature-disabled
  block (the threshold was already set to that value) and the trailing reset,
  which is dead since each test is rolled back.

No behaviour change.

* fix: compute overdue amount from payment terms, matching the Overdue status

The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets
to the LAST payment term. An invoice whose first term was past due and unpaid
was therefore counted as zero, even though ERPNext already shows it as Overdue
in the invoice list. The gate and the UI could disagree.

get_customer_overdue_amount now follows the same rule as is_overdue(): per
invoice, the amount that has fallen due (sum of payment schedule terms past
their due date) minus what has been paid, clamped to the outstanding balance.
Invoices without a schedule (POS, opening) still fall back to the invoice due
date, mirroring is_overdue()'s own guard.

The ledger stays the source of truth for what is unpaid: the outstanding per
invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is
always stored in company currency, so no currency conversion is needed and the
comparison against the threshold stays consistent.

Adds a test covering a two-term invoice: only the past-due term counts, and
paying it off clears the overdue amount.

* feat: honour the overdue billing threshold set on the customer group

The threshold lives on Customer Credit Limit, which is also rendered on
Customer Group. A threshold set there was stored but never evaluated, so the
configuration was a silent no-op.

get_overdue_billing_threshold now reads the customer's row and falls back to
its customer group, mirroring get_credit_limit. The group's
bypass_credit_limit_check is deliberately not consulted: it is labelled for the
credit limit check at sales order and is unrelated to overdue billing.

get_customer_group_details also dropped the threshold when copying group rows
onto a customer, because it copied a single hardcoded field per table. It now
copies a list of fields per table, so credit_limit and overdue_billing_threshold
both carry over.

* refactor: clearer labels for the overdue billing control (#57298)

refactor: clearer labels and messages, drop "threshold" wording

User-facing text only, no field or behaviour changes:

- Accounts Settings toggle label -> "Restrict Customer Over Billing".
- Bypass role label -> "Role Allowed to Bypass Over Billing Restriction".
- Customer Credit Limit field label -> "Overdue Limit".
- Rewrote the descriptions and the block message to match and to stop
  saying "threshold".

* fix: treat zero overdue limit as opt-out and isolate settings in test

get_overdue_billing_threshold treated an explicit 0 on the customer's credit
limit row as "not set" and fell back to the customer group. A customer could
not be exempted from the group restriction while keeping a credit limit row,
and every existing row defaults to 0, so enabling the feature on a group blocked
all its customers that had any credit limit row. Guard the group fallback on
"threshold is None" (no row for the company) instead of a falsy check, so an
explicit 0 acts as an opt-out.

test_overdue_billing_threshold_on_submit mutated the Accounts Settings singleton
without restoring it, so a failed assertion mid-test leaked
enable_overdue_billing_threshold and the bypass role into later tests that submit
sales invoices. Wrap the mutations in try/finally and restore the originals.

* fix: let a zero customer overdue limit inherit the group's limit

A 0 on the customer's credit limit row falls back to the customer group again;
only a non-zero value on the customer overrides the group. Reverts the earlier
opt-out interpretation and updates the fallback test to expect the group's limit.
2026-07-24 15:33:26 +05:30
Mihir Kandoi
2b8b6a09dd Merge pull request #57440 from frappe/mergify/bp/version-16-hotfix/pr-57435
fix(accounts): respect user permissions in party dashboard company list (backport #57435)
2026-07-24 15:12:35 +05:30
pandiyan
c79f2e45a8 fix: respect user permissions in party dashboard company list
use frappe.get_list instead of frappe.get_all in get_dashboard_info so
the company list honors user permissions. previously, a party with
invoices across multiple companies would raise "User don't have
permissions to select/read this account" for users restricted to a
subset of companies, since get_party_account was called for companies
the user could not access.

fixes frappe/erpnext#57428

(cherry picked from commit 903c87bcaa)
2026-07-24 09:30:41 +00:00
Mihir Kandoi
ab4b5abb91 Merge pull request #57420 from aerele/backport-57412-customer-pick-list-v16
fix: map pick list customer to delivery note when no sales order
2026-07-24 14:58:23 +05:30
pandiyan
3f6501b4ff fix: map pick list customer to delivery note when no sales order
backport of #57412
2026-07-24 11:12:17 +05:30
Mihir Kandoi
2206e9fe2a Merge pull request #57415 from frappe/mergify/bp/version-16-hotfix/pr-57413
fix: typeerror in get_batches_by_oldest for mixed batch expiry (backport #57413)
2026-07-23 18:38:47 +05:30
pandiyan
bbe7580c9d fix: typeerror in get_batches_by_oldest for mixed batch expiry
sort on (expiry is none, expiry) so a null expiry_date is never
order-compared against a datetime.date, which raised typeerror in
python 3 when a warehouse held both dated and never-expiring batches.

(cherry picked from commit 62c9f8ee3e)
2026-07-23 12:53:51 +00:00
Mihir Kandoi
ef21a9ba9b Merge pull request #57250 from frappe/mergify/bp/version-16-hotfix/pr-57245 2026-07-23 16:25:43 +05:30
Mihir Kandoi
88f9039d1a Merge pull request #57408 from frappe/mergify/bp/version-16-hotfix/pr-57400
fix: guard against missing is_your_company_address custom field on ad… (backport #57400)
2026-07-23 16:21:06 +05:30
pandiyan
2e6b4b5838 fix: guard against missing is_your_company_address custom field on address
(cherry picked from commit ea3ed8b836)
2026-07-23 10:41:18 +00:00
Mihir Kandoi
85d793b690 Merge pull request #57404 from mihir-kandoi/fix-stock-ageing-batch-pool-rebalance-v16
fix: rebalance batch slot values at the pooled rate when driven negative (backport #57403)
2026-07-23 16:09:11 +05:30
Mihir Kandoi
8a50572786 fix: rebalance batch slot values at the pooled rate when driven negative
A batch is one valuation pool, so consumption is valued at the pooled
rate while slots may carry stale intra-batch detail (e.g. units
reconciled at zero and later merged). Consuming such a slot leaves a
negative value on positive qty. Spread the pool value across the
batch's slots when that happens; non-batchwise slots pool per
warehouse.
2026-07-23 15:57:13 +05:30
mergify[bot]
bbd942c600 feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57385)
feat: make Shipping Rule Cost Center optional with company default fallback (#57355)

feat(shipping-rule): make cost center optional with company default fallback

Cost Center on Shipping Rule is no longer mandatory. When left blank, the
applied shipping tax row falls back to the company default cost center,
avoiding the 'Cost Center is required for Profit and Loss account' error on
submit. The rule's project is also applied to the tax row.

(cherry picked from commit a47f25896b)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-23 15:42:28 +05:30
mergify[bot]
84813d7f46 fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (backport #57380) (#57397)
* fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (#57380)

* fix: shift same-timestamp sibling SLEs when cancelling an entry

update_qty_in_future_sle compared against the reversal SLE's own
creation and skipped same-posting_datetime siblings on cancel, leaving
their qty_after_transaction stale and causing false negative stock
errors.

* fix: revert update_qty_in_future_sle cancel tie-break, it double-counted

(cherry picked from commit 8c0ec3c179)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py

* chore: fix conflicts

Fix test case for cancelling stock ledger entries with the same timestamp to ensure correct behavior.

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-23 10:08:20 +00:00
Shllokkk
1132eb1a0f fix: respect selected BOM when creating work order for variant item (#57359)
* fix: respect selected BOM when creating work order for variant item

* fix: add type hints to make_work_order
2026-07-23 13:48:45 +05:30
kaulith
2323f2c978 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-23 11:59:44 +05:30
Mihir Kandoi
e277647f7b Merge pull request #57362 from frappe/mergify/bp/version-16-hotfix/pr-57361
refactor: move new-doc route options to StockController (backport #57361)
2026-07-22 18:04:50 +05:30
Mihir Kandoi
c587f4934a refactor: move new-doc route options to StockController
set_route_options_for_new_doc lived in TransactionController, so doctypes
extending StockController directly (Stock Reconciliation, Stock Entry) missed
the Batch/SABB prefill or duplicated it locally. Move it to StockController
and call it from onload_post_render so all descendants inherit it.

- Batch quick entry from Stock Reconciliation items now prefills Item
- SABB route options unified: warehouse || s_warehouse || t_warehouse,
  so transaction doctypes now also prefill warehouse
- Stock Entry's duplicate handler removed; its onload_post_render now
  calls super

(cherry picked from commit 551559e804)
2026-07-22 12:33:46 +00:00
Mihir Kandoi
e0020b478a Merge pull request #57354 from mihir-kandoi/fix/reserved-batch-precision-v16
fix: get reserved batch qty precision from settings (v16)
2026-07-22 15:11:23 +05:30
Mihir Kandoi
95de2374ed fix: get reserved batch qty precision from settings 2026-07-22 14:58:11 +05:30
kaulith
d70e005412 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-19 11:38:25 +05:30
Kaushal Shriwas
32baf6a47c fix: force-delete repost data file during cleanup (backport #57245) 2026-07-17 22:52:30 +05:30
Abdeali Chharchhoda
3f87836536 fix: enhance growth view filtering by validating period keys
(cherry picked from commit aad287d09e)
2026-07-08 10:44:41 +00:00
Abdeali Chharchhoda
da3844c4df fix: update formatting of growth view for FS report
(cherry picked from commit 4c7499600c)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
1f5281d3b8 refactor: simple utility for growth value computation for custom FS report
(cherry picked from commit 698876672d)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
04e7457cea refactor(financial-report): fix row transformation for growth calculations
(cherry picked from commit c179460c98)
2026-07-08 10:44:39 +00:00
48 changed files with 1843 additions and 530 deletions

View File

@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.is_your_company_address:
if self.get("is_your_company_address"):
return
return super().link_address()
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -75,6 +75,8 @@
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11",
"assets_tab",
"asset_settings_section",
@@ -271,6 +273,21 @@
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role Allowed to Bypass Over Billing Restriction",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break"

View File

@@ -77,6 +77,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check
enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -95,6 +96,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
@@ -150,6 +152,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription)
clear_cache = True
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -241,6 +247,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
def toggle_overdue_billing_threshold_field(hide):
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,

View File

@@ -616,15 +616,27 @@ class ExchangeRateRevaluation(Document):
if journals:
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
if drafts := frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": ["in", journals]},
pluck="name",
as_list=1,
):
part = "journals are" if len(drafts) > 1 else "journal is"
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
frappe.throw(
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
)
else:
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.save()
frappe.msgprint(
_("A draft reverse journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):

View File

@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
class TestExchangeRateRevaluation(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.item = "_Test Item"
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.set_system_and_company_settings()
def set_system_and_company_settings(self):
# set number and currency precision
system_settings = frappe.get_doc("System Settings")
system_settings.float_precision = 2
system_settings.currency_precision = 2
system_settings.language = "en"
system_settings.time_zone = "Asia/Kolkata"
system_settings.save()
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
@@ -300,3 +291,97 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_05_revaluation_journal_reversal(self):
"""
Test reversing of revaluation journals
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer="_Test Customer 1",
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
self.assertEqual(len(err.accounts), 1)
err.save().submit()
gain_loss_account = err.get_for_unrealized_gain_loss_account()
usd_account = err.accounts[0].account
old_balance = err.accounts[0].balance_in_base_currency
new_balance = err.accounts[0].new_balance_in_base_currency
total_gain_loss = err.total_gain_loss
# Create JV for ERR
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(len(je.accounts), 3)
# A gain is credited to the gain/loss account, a loss is debited. The current
# exchange rate (from master data) may sit either side of the booked rate, so
# derive the column from the sign instead of assuming a gain.
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
]
actual = []
for acc in je.accounts:
actual.append(
(
acc.account,
acc.debit,
acc.credit,
acc.debit_in_account_currency,
acc.credit_in_account_currency,
)
)
self.assertEqual(expected, actual)
# Assert reversals are not posted
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
# submit
draft = frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
pluck="name",
as_list=1,
)
self.assertIsNotNone(draft)
frappe.get_doc("Journal Entry", draft[0]).submit()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))
reverse_jv = frappe.db.get_all(
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
)
self.assertIsNotNone(reverse_jv)

View File

@@ -1853,28 +1853,51 @@ class GrowthViewTransformer:
self.formatted_rows = context.raw_data.get("formatted_data", [])
self.period_list = context.period_list
def transform(self) -> None:
def transform(self):
for row_data in self.formatted_rows:
if row_data.get("is_blank_line"):
continue
transformed_values = {}
for i in range(len(self.period_list)):
current_period = self.period_list[i]["key"]
if row_data.get("segment_values"):
self._transform_segmented_row(row_data)
else:
self._transform_single_row(row_data)
current_value = row_data[current_period]
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
def _compute_growth_values(self, source: dict) -> dict:
transformed = {}
if i == 0:
transformed_values[current_period] = current_value
else:
growth_percent = self._calculate_growth(previous_value, current_value)
transformed_values[current_period] = growth_percent
for i, period in enumerate(self.period_list):
current_period = period["key"]
current_value = source.get(current_period)
row_data.update(transformed_values)
if current_value in (None, ""):
continue
if i == 0:
transformed[current_period] = current_value
else:
previous_period = self.period_list[i - 1]["key"]
previous_value = source.get(previous_period) or 0
transformed[current_period] = self._calculate_growth(previous_value, current_value)
return transformed
def _transform_single_row(self, row_data: dict):
row_data.update(self._compute_growth_values(row_data))
def _transform_segmented_row(self, row_data: dict):
for seg_id, seg_data in row_data.get("segment_values", {}).items():
if seg_data.get("is_blank_line"):
continue
transformed = self._compute_growth_values(seg_data)
seg_data.update(transformed)
for period_key, value in transformed.items():
row_data[f"{seg_id}_{period_key}"] = value
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
if current_value is None:
if current_value in (None, ""):
return None
if previous_value == 0 and current_value > 0:

View File

@@ -567,6 +567,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
pos_invoice_doc.cancel()
def check_overdue_billing_threshold(self):
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
check_overdue_billing_threshold(self.customer, self.company)
@frappe.whitelist()
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)

View File

@@ -82,8 +82,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -141,19 +140,20 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2024-03-27 13:10:41.653314",
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -197,7 +197,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
ShippingRuleCountry,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
cost_center: DF.Link
cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types

View File

@@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_all(
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -55,8 +55,7 @@ class ReceivablePayableReport:
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
if "calculate_ageing_with" not in self.filters
or self.filters.calculate_ageing_with == "Today Date"
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
else self.filters.report_date
)

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -1371,8 +1371,9 @@ class StockController(AccountsController):
if outstanding > 0:
reservations[key].append(row)
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 0:
if flt(reserved_qty, precision) <= 0:
continue
batch_qty = get_batch_qty(
@@ -1383,7 +1384,7 @@ class StockController(AccountsController):
consider_negative_batches=True,
)
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
continue
vouchers = ", ".join(

View File

@@ -279,13 +279,17 @@ class Opportunity(TransactionBase, CRMNote):
self.save()
else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
def has_active_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
{
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
},
"name",
)
else:
@@ -294,14 +298,20 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
self.name,
)
def has_ordered_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
"Quotation",
{
"opportunity": self.name,
"status": ("in", ["Ordered", "Partially Ordered"]),
"docstatus": 1,
},
"name",
)
else:
return frappe.db.sql(
@@ -309,7 +319,7 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
and q.status in ('Ordered', 'Partially Ordered')""",
self.name,
)

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,11 @@ frappe.ui.form.on("BOM Operation", {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
hour_rate: function (frm, cdt, cdn) {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
});
frappe.tour["Routing"] = [

View File

@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
@frappe.whitelist()
def make_work_order(
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
bom_no: str,
item: str,
qty: float = 0,
company: str | None = None,
project: str | None = None,
variant_items: str | list | None = None,
use_multi_level_bom: bool | None = None,
):
from erpnext import get_default_company
@@ -2563,7 +2569,8 @@ def make_work_order(
item_details = get_item_details(item, project)
if frappe.db.get_value("Item", item, "variant_of"):
# selected BOM already belongs to this variant — keep it
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
if variant_bom := frappe.db.get_value(
"BOM",
{"item": item, "is_default": 1, "docstatus": 1},

View File

@@ -491,3 +491,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.rename_ar_ap_ageing_filter

View File

@@ -0,0 +1,45 @@
import frappe
REPORTS = (
"Accounts Receivable",
"Accounts Payable",
"Accounts Receivable Summary",
"Accounts Payable Summary",
)
def execute():
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
_migrate("Auto Email Report", "filters", "report")
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
_migrate("Number Card", "filters_json", "report_name", type_field="type")
def _migrate(doctype, filter_field, report_field, type_field=None):
conditions = {report_field: ("in", REPORTS)}
if type_field:
conditions[type_field] = "Report"
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
updated = _rewrite(row.get(filter_field))
if updated is not None:
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
def _rewrite(raw):
if not raw:
return None
try:
filters = frappe.parse_json(raw)
except ValueError:
return None
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
return None
filters["age_as_on"] = filters.pop("calculate_ageing_with")
if filters["age_as_on"] == "Today Date":
filters["age_as_on"] = "Today"
return frappe.as_json(filters, indent=None)

View File

@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
}
}
onload_post_render() {
this.set_route_options_for_new_doc();
}
set_route_options_for_new_doc() {
// While creating a Batch or Serial and Batch Bundle from the link
// field, copy details from the line item to the new form
if (!this.frm.fields_dict.items) return;
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = (row) => {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
voucher_type: this.frm.doc.doctype,
};
};
}
}
barcode(doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.barcode) {

View File

@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
erpnext.toggle_serial_batch_fields(this.frm);
}
set_route_options_for_new_doc() {
// While creating the batch from the link field, copy item from line item to batch form
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
}
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: this.frm.doc.doctype,
};
};
}
}
}
scan_barcode() {
frappe.flags.dialog_set = false;
this.barcode_scanner.process_scan();

View File

@@ -41,6 +41,26 @@ erpnext.financial_statements = {
_is_special_view: function (column, data) {
if (!data) return false;
const view = get_filter_value("selected_view");
if (!["Growth", "Margin"].includes(view)) return false;
if (get_filter_value("report_template")) {
const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data);
// Account column
if (columnInfo.isAccount) return false;
const periodKeys = data._segment_info?.period_keys || [];
if (!periodKeys.includes(columnInfo.fieldname)) return false;
if (view === "Growth") {
// First period of new segment
if (periodKeys[0] === columnInfo.fieldname) return false;
}
return true;
}
return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2);
},

View File

@@ -475,10 +475,10 @@
"report_hide": 1
},
{
"description": "Transactions are blocked or warned when outstanding balance exceeds this amount.",
"description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit",
"show_description_on_click": 1
},

View File

@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.query_builder import Field, functions
from frappe.utils import cint, cstr, flt, get_formatted_email, today
from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today
from frappe.utils.user import get_users_with_role
from erpnext.accounts.party import (
@@ -204,17 +204,21 @@ class Customer(TransactionBase):
self.credit_limits = []
self.payment_terms = self.default_price_list = ""
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
tables = [
["accounts", ["account"]],
["credit_limits", ["credit_limit", "overdue_billing_threshold"]],
]
fields = ["payment_terms", "default_price_list"]
for row in tables:
table, field = row[0], row[1]
table, table_fields = row[0], row[1]
if not doc.get(table):
continue
for entry in doc.get(table):
child = self.append(table)
child.update({"company": entry.company, field: entry.get(field)})
child.update({"company": entry.company})
child.update({field: entry.get(field) for field in table_fields})
for field in fields:
if not doc.get(field):
@@ -403,6 +407,9 @@ class Customer(TransactionBase):
else:
company_record.append(limit.company)
if not flt(limit.credit_limit):
continue
outstanding_amt = get_customer_outstanding(
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
)
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message)
def check_overdue_billing_threshold(customer: str, company: str) -> None:
if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"):
return
threshold = get_overdue_billing_threshold(customer, company)
if not threshold:
return
overdue_amount = get_customer_overdue_amount(customer, company)
if overdue_amount <= threshold:
return
bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing")
if bypass_role and bypass_role in frappe.get_roles():
return
company_currency = frappe.get_cached_value("Company", company, "default_currency")
frappe.throw(
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
customer,
fmt_money(overdue_amount, currency=company_currency),
fmt_money(threshold, currency=company_currency),
),
title=_("Overdue Limit Crossed"),
)
def get_overdue_billing_threshold(customer: str, company: str) -> float:
"""Overdue limit set on the customer, falling back to its customer group."""
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer, "parenttype": "Customer", "company": company},
"overdue_billing_threshold",
)
if not threshold:
customer_group = frappe.get_cached_value("Customer", customer, "customer_group")
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer_group, "parenttype": "Customer Group", "company": company},
"overdue_billing_threshold",
)
return flt(threshold)
def get_customer_overdue_amount(customer: str, company: str) -> float:
"""Amount the customer owes past its due date, in company currency.
Follows the same rule as the Overdue invoice status, so a customer is only
blocked for what the invoice list already shows as overdue.
"""
invoices = get_outstanding_invoices_for_customer(customer, company)
if not invoices:
return 0.0
payable_amounts = get_past_due_payable_amounts([d.name for d in invoices])
return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices))
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
from frappe.query_builder.functions import Sum
gl_entry = frappe.qb.DocType("GL Entry")
sales_invoice = frappe.qb.DocType("Sales Invoice")
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
return (
frappe.qb.from_(gl_entry)
.inner_join(sales_invoice)
.on(sales_invoice.name == gl_entry.against_voucher)
.select(
sales_invoice.name,
sales_invoice.due_date,
sales_invoice.base_grand_total,
outstanding.as_("outstanding"),
)
.where(gl_entry.party_type == "Customer")
.where(gl_entry.party == customer)
.where(gl_entry.company == company)
.where(gl_entry.is_cancelled == 0)
.where(gl_entry.against_voucher_type == "Sales Invoice")
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
.having(outstanding > 0)
).run(as_dict=True)
def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]:
from frappe.query_builder.functions import Sum
payment_schedule = frappe.qb.DocType("Payment Schedule")
rows = (
frappe.qb.from_(payment_schedule)
.select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable"))
.where(payment_schedule.parenttype == "Sales Invoice")
.where(payment_schedule.parent.isin(invoices))
.where(payment_schedule.due_date < getdate())
.groupby(payment_schedule.parent)
).run(as_dict=True)
return {d.parent: flt(d.payable) for d in rows}
def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float:
outstanding = flt(invoice.outstanding)
# No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are
# all still in the future. Both are answered by the invoice due date, which is the last term.
if payable_amount is None:
return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0
paid = flt(invoice.base_grand_total) - outstanding
return min(max(payable_amount - paid, 0.0), outstanding)
def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
# Outstanding based on GL Entries
cond = ""

View File

@@ -5,13 +5,15 @@
import json
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, getdate, nowdate
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
get_customer_overdue_amount,
get_overdue_billing_threshold,
make_quotation,
parse_full_name,
)
@@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite):
"company": "_Test Company",
"account": "Creditors - _TC",
}
test_credit_limits = {"company": "_Test Company", "credit_limit": 350000}
test_credit_limits = {
"company": "_Test Company",
"credit_limit": 350000,
"overdue_billing_threshold": 5000,
}
doc.append("accounts", test_account_details)
doc.append("credit_limits", test_credit_limits)
doc.insert()
@@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
c_doc.delete()
doc.delete()
@@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, customer.save)
def test_get_customer_overdue_amount(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# a past-due, unpaid invoice adds its outstanding to the overdue amount
create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
# an invoice due today (not yet past due) does not
create_sales_invoice(qty=1, rate=700, posting_date=nowdate())
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
def test_get_customer_overdue_amount_is_in_company_currency(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company")
# 100 USD at a conversion rate of 50 must be counted as 5000 in company currency
create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
qty=1,
rate=100,
posting_date=add_days(nowdate(), -30),
)
self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000)
def test_get_customer_overdue_amount_follows_payment_terms(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
def make_invoice_with_terms():
si = create_sales_invoice(
qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True
)
si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50})
si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50})
si.insert()
si.submit()
return si
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date
# is the last term (in 30 days), so this is only caught by reading the payment schedule.
si = make_invoice_with_terms()
self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30)))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600)
# paying off the past-due term clears the overdue amount
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
pe.reference_no = "_Test Overdue Payment"
pe.reference_date = nowdate()
pe.paid_amount = pe.received_amount = 600
pe.references[0].allocated_amount = 600
pe.insert()
pe.submit()
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
def test_overdue_billing_threshold_on_submit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30))
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
settings = frappe.get_single("Accounts Settings")
original_enable = settings.enable_overdue_billing_threshold
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
try:
settings.enable_overdue_billing_threshold = 1
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
# overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit)
# a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
finally:
settings.enable_overdue_billing_threshold = original_enable
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
settings.save()
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
group = frappe.get_doc("Customer Group", customer_group)
group.credit_limits = []
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
group.save()
# the customer has no threshold of its own, so the group's applies
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
# a threshold on the customer wins over the group
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
# a 0 on the customer inherits the group's limit
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
def test_overdue_threshold_row_without_credit_limit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
# outstanding must be > 0 so a 0 credit_limit would previously trip the check
create_sales_invoice(qty=1, rate=500)
customer = frappe.get_doc("Customer", "_Test Customer")
customer.credit_limits = []
customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000})
customer.save()
self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000)
self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0)
def test_customer_payment_terms(self):
frappe.db.set_value(
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
@@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert()
def set_overdue_billing_threshold(customer, company, threshold):
customer = frappe.get_doc("Customer", customer)
for d in customer.credit_limits:
if d.company == company:
d.overdue_billing_threshold = threshold
d.db_update()
return
customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold})
customer.credit_limits[-1].db_insert()
def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
if not customer_name:
customer_name = represents_company

View File

@@ -8,6 +8,7 @@
"company",
"column_break_2",
"credit_limit",
"overdue_billing_threshold",
"bypass_credit_limit_check"
],
"fields": [
@@ -18,6 +19,15 @@
"in_list_view": 1,
"label": "Credit Limit"
},
{
"columns": 3,
"description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.",
"fieldname": "overdue_billing_threshold",
"fieldtype": "Currency",
"hidden": 1,
"in_list_view": 1,
"label": "Overdue Limit"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"

View File

@@ -18,6 +18,7 @@ class CustomerCreditLimit(Document):
bypass_credit_limit_check: DF.Check
company: DF.Link | None
credit_limit: DF.Currency
overdue_billing_threshold: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -132,7 +132,7 @@
{
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit"
}
],

View File

@@ -75,6 +75,7 @@ class DeprecatedSerialNoValuation:
| (table.serial_no.like("%\n" + serial_no))
| (table.serial_no.like("%\n" + serial_no + "\n%"))
)
& (table.item_code == self.sle.item_code)
& (table.company == self.sle.company)
& (table.warehouse == self.sle.warehouse)
& (table.serial_and_batch_bundle.isnull())

View File

@@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse):
"""Returns the oldest batch and qty for the given item_code and warehouse"""
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
batches_dates.sort(key=lambda tup: tup[1])
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates

View File

@@ -175,7 +175,7 @@ class Bin(Document):
& (subcontract_order.docstatus == 1)
)
if subcontract_doctype == "Purchase Order"
else (subcontract_order.docstatus == 1)
else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1))
)
)
@@ -212,6 +212,7 @@ class Bin(Document):
else (
(Coalesce(se.subcontracting_order, "") != "")
& (subcontract_order.name == se.subcontracting_order)
& (subcontract_order.status != "Closed")
)
)
)

View File

@@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None):
target_doc.company = pick_list.company
if not target_doc.customer:
target_doc.customer = pick_list.customer
item_table_mapper_without_so = {
"doctype": f"{target} Item",
"field_map": {

View File

@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
def clear_attachment(self):
if attachments := get_attachments(self.doctype, self.name):
attachment = attachments[0]
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
if self.reposting_data_file:
self.db_set("reposting_data_file", None)

View File

@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
)
)
def test_clear_attachment_skips_referenced_data_file(self):
riv = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"company": "_Test Company",
"item_code": "_Test Item",
"warehouse": "_Test Warehouse - _TC",
"posting_date": today(),
}
).insert(ignore_permissions=True)
attached = frappe.get_doc(
{
"doctype": "File",
"file_name": "repost_data.json.gz",
"content": "test",
"attached_to_doctype": riv.doctype,
"attached_to_name": riv.name,
}
).insert(ignore_permissions=True)
riv.db_set("reposting_data_file", attached.file_url)
riv.clear_attachment()
self.assertFalse(frappe.db.exists("File", attached.name))
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
@ERPNextTestSuite.change_settings(
"Stock Reposting Settings",
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},

View File

@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
frm.events.set_route_options_for_new_doc(frm);
frm.set_df_property(
"items",
"cannot_add_rows",
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
);
},
set_route_options_for_new_doc(frm) {
let batch_no_field = frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: frm.doc.doctype,
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
};
};
}
},
get_items_from_transit_entry: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
onload_post_render() {
super.onload_post_render();
var me = this;
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
me.company();

View File

@@ -1441,6 +1441,11 @@ class StockEntry(StockController, SubcontractingInwardController):
"""
# Set rate for outgoing items
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
<<<<<<< HEAD
=======
raise_error_if_no_rate = raise_error_if_no_rate and not self.is_new()
has_consumption_basis = self.has_consumption_basis()
>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334))
items = []
# Set basic rate for incoming items
@@ -1454,6 +1459,7 @@ class StockEntry(StockController, SubcontractingInwardController):
d.basic_amount = 0.0
continue
<<<<<<< HEAD
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
d.basic_rate = 0.0
items.append(d.item_code)
@@ -1464,6 +1470,16 @@ class StockEntry(StockController, SubcontractingInwardController):
)
elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
=======
self._set_incoming_item_rate(
d,
outgoing_items_cost,
raise_error_if_no_rate,
zero_valuation_items,
bom_cost_allocation_per,
has_consumption_basis,
)
>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334))
if self.bom_no:
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
@@ -1476,9 +1492,61 @@ class StockEntry(StockController, SubcontractingInwardController):
if cost_allocation_per and flt(d.transfer_qty):
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
<<<<<<< HEAD
if not d.basic_rate and not d.allow_zero_valuation_rate:
if self.is_new():
raise_error_if_no_rate = False
=======
def has_consumption_basis(self) -> bool:
"""Whether the cost of the consumed items is known, even when that cost is zero."""
if any(d.s_warehouse for d in self.get("items")):
return True
settings = frappe.get_single("Manufacturing Settings")
if settings.material_consumption and settings.get_rm_cost_from_consumption_entry and self.work_order:
return bool(self.get_consumption_entries())
return False
def get_consumption_entries(self) -> list[str]:
# Cached: queried in both has_consumption_basis() and _get_rm_cost_for_manufacture()
if getattr(self, "_consumption_entries", None) is None:
self._consumption_entries = frappe.get_all(
"Stock Entry",
filters={
"docstatus": 1,
"work_order": self.work_order,
"purpose": "Material Consumption for Manufacture",
},
pluck="name",
)
return self._consumption_entries
def _set_incoming_item_rate(
self,
d,
outgoing_items_cost,
raise_error_if_no_rate,
zero_valuation_items,
bom_cost_allocation_per=None,
has_consumption_basis=False,
):
rate_derived_from_consumption = False
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
d.basic_rate = 0.0
zero_valuation_items.append(d.item_code)
elif d.is_finished_item:
if self.purpose == "Manufacture":
d.basic_rate = self.get_basic_rate_for_manufactured_item(
d.transfer_qty, outgoing_items_cost, has_consumption_basis
)
rate_derived_from_consumption = has_consumption_basis
elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
# Repack rate comes from consumed source-warehouse rows, not consumption entries
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334))
d.basic_rate = get_valuation_rate(
d.item_code,
@@ -1493,9 +1561,27 @@ class StockEntry(StockController, SubcontractingInwardController):
serial_and_batch_bundle=d.serial_and_batch_bundle,
)
<<<<<<< HEAD
# do not round off basic rate to avoid precision loss
d.basic_rate = flt(d.basic_rate)
d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount"))
=======
# A rate of zero derived from the consumed items is their actual cost, not a missing
# rate. Falling back to the item's valuation here would value free inputs as output.
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
d.basic_rate = get_valuation_rate(
d.item_code,
d.t_warehouse,
self.doctype,
self.name,
d.allow_zero_valuation_rate,
currency=erpnext.get_company_currency(self.company),
company=self.company,
raise_error_if_no_rate=raise_error_if_no_rate,
batch_no=d.batch_no,
serial_and_batch_bundle=d.serial_and_batch_bundle,
)
>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334))
if items:
message = ""
@@ -1564,11 +1650,14 @@ class StockEntry(StockController, SubcontractingInwardController):
)
return flt(outgoing_items_cost / total_fg_qty)
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
def get_basic_rate_for_manufactured_item(
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
) -> float:
settings = frappe.get_single("Manufacturing Settings")
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item])
if settings.material_consumption:
<<<<<<< HEAD
if settings.get_rm_cost_from_consumption_entry and self.work_order:
# Validate only if Material Consumption Entry exists for the Work Order.
if frappe.db.exists(
@@ -1631,6 +1720,77 @@ class StockEntry(StockController, SubcontractingInwardController):
return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty)
=======
outgoing_items_cost = self._get_rm_cost_for_manufacture(
settings, finished_item_qty, outgoing_items_cost, has_consumption_basis
)
return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty)
def _get_rm_cost_for_manufacture(
self, settings, finished_item_qty, outgoing_items_cost, has_consumption_basis=False
):
if settings.get_rm_cost_from_consumption_entry and self.work_order:
if self.get_consumption_entries():
self._validate_no_raw_materials_in_manufacture_entry(settings)
self._validate_single_manufacture_entry()
return self._fetch_consumption_entry_cost()
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
# real cost, so substituting BOM rates would value free inputs as output.
elif not outgoing_items_cost and not has_consumption_basis:
bom_items = self.get_bom_raw_materials(finished_item_qty)
outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])
return outgoing_items_cost
def _validate_no_raw_materials_in_manufacture_entry(self, settings):
for item in self.items:
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item:
label = frappe.get_meta(settings.doctype).get_label("get_rm_cost_from_consumption_entry")
frappe.throw(
_(
"Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials."
).format(
item.idx,
frappe.bold(label),
frappe.bold(_("Manufacture")),
frappe.bold(_("Material Consumption for Manufacture")),
)
)
def _validate_single_manufacture_entry(self):
if frappe.db.exists(
"Stock Entry",
{
"docstatus": 1,
"work_order": self.work_order,
"purpose": "Manufacture",
"name": ("!=", self.name),
},
):
frappe.throw(
_("Only one {0} entry can be created against the Work Order {1}").format(
frappe.bold(_("Manufacture")), frappe.bold(self.work_order)
)
)
def _fetch_consumption_entry_cost(self):
SE = frappe.qb.DocType("Stock Entry")
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
return (
frappe.qb.from_(SE)
.left_join(SE_ITEM)
.on(SE.name == SE_ITEM.parent)
.select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
.where(
(SE.docstatus == 1)
& (SE.work_order == self.work_order)
& (SE.purpose == "Material Consumption for Manufacture")
)
).run()[0][0] or 0
>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334))
def distribute_additional_costs(self):
# If no incoming items, set additional costs blank
if not any(d.item_code for d in self.items if d.t_warehouse):

View File

@@ -2654,6 +2654,149 @@ class TestStockEntry(ERPNextTestSuite):
se.save()
se.submit()
def test_manufacture_with_zero_valued_raw_material(self):
# A finished good produced from free inputs is worth nothing. Falling back to the item's
# own valuation would create value out of nothing and inflate it on every production run.
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
fg_warehouse = "Finished Goods - _TC"
rm_receipt = make_stock_entry(item_code=rm_item, target=warehouse, qty=100, rate=0, do_not_save=True)
rm_receipt.items[0].allow_zero_valuation_rate = 1
rm_receipt.save()
rm_receipt.submit()
# the finished good already carries a valuation in the target warehouse
make_stock_entry(item_code=fg_item, target=fg_warehouse, qty=10, rate=100)
se = frappe.new_doc("Stock Entry")
se.purpose = se.stock_entry_type = "Manufacture"
se.company = "_Test Company"
se.append(
"items",
{"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1},
)
se.append(
"items",
{
"item_code": fg_item,
"t_warehouse": fg_warehouse,
"qty": 10,
"is_finished_item": 1,
"conversion_factor": 1,
},
)
se.save()
self.assertEqual(se.items[0].basic_amount, 0)
self.assertEqual(se.items[1].basic_rate, 0)
self.assertEqual(se.items[1].basic_amount, 0)
se.submit()
fg_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name, "item_code": fg_item, "is_cancelled": 0},
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(fg_sle.incoming_rate, 0)
self.assertEqual(fg_sle.stock_value_difference, 0)
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
receipt = make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, rate=0, do_not_save=True)
receipt.items[0].allow_zero_valuation_rate = 1
receipt.save()
receipt.submit()
wo = make_wo_order_test_record(production_item=fg_item, bom_no=bom_no, qty=10)
transfer = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Material Transfer for Manufacture", 10))
transfer.items[0].s_warehouse = "Stores - _TC"
transfer.insert().submit()
return wo
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 0}
)
def test_manufacture_does_not_fall_back_to_bom_cost_for_free_raw_material(self):
# The BOM is only an estimate for when nothing was consumed. Items that were consumed and
# cost nothing are a real cost, so a BOM rate must not stand in for them.
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": rm_item,
"price_list": "_Test Price List India",
"price_list_rate": 150,
"buying": 1,
}
).insert()
# price the BOM off the price list so that it carries a rate the free stock does not
bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True)
bom.rm_cost_as_per = "Price List"
bom.buying_price_list = "_Test Price List India"
bom.currency = "INR"
bom.save()
bom.submit()
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom.name)
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
manufacture.save()
fg_row = next(d for d in manufacture.items if d.is_finished_item)
self.assertEqual(fg_row.basic_rate, 0)
self.assertEqual(fg_row.basic_amount, 0)
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
)
def test_manufacture_with_zero_valued_consumption_entry(self):
# The raw material is consumed by a separate entry, so the Manufacture entry carries no
# consumed rows of its own. Its cost is still known, and it is zero.
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
# the finished good already carries a valuation in the work order's target warehouse
make_stock_entry(item_code=fg_item, target="_Test Warehouse 1 - _TC", qty=10, rate=100)
bom = make_bom(item=fg_item, raw_materials=[rm_item]).name
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom)
consumption = frappe.get_doc(
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
)
consumption.insert().submit()
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
manufacture.save()
fg_row = next(d for d in manufacture.items if d.is_finished_item)
self.assertEqual(fg_row.basic_rate, 0)
self.assertEqual(fg_row.basic_amount, 0)
def test_disassemble_entry_without_wo(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom

View File

@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
self.assertEqual(qty_after(receipt2), 10)
def test_cancel_shifts_same_timestamp_delivery_notes(self):
item = make_item().name
warehouse = "_Test Warehouse - _TC"
posting_date = today()
posting_time = "10:00:00"
make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=100,
rate=10,
posting_date=posting_date,
posting_time="09:00:00",
)
dns = []
for i in range(5):
dns.append(
create_delivery_note(
item_code=item,
warehouse=warehouse,
qty=20,
rate=10 * i,
posting_date=posting_date,
posting_time=posting_time,
)
)
time.sleep(1)
dn = dns[2]
dn.cancel()
expected_qty_after_transaction_of_dns3 = 40
qty_after_transaction_of_dns3 = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dns[3].name, "is_cancelled": 0},
"qty_after_transaction",
)
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
def test_get_next_stock_reco_respects_creation_order(self):
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
# "next" reco when it was created after that entry. A reco created before it actually precedes

View File

@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
};
});
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse,
voucher_type: frm.doc.doctype,
};
};
}
if (frm.doc.company) {
erpnext.queries.setup_queries(frm, "Warehouse", function () {
return erpnext.queries.warehouse(frm.doc);

View File

@@ -325,6 +325,7 @@ class FIFOSlots:
del stock_ledger_entries
self._recompute_moving_average_slots()
self._rebalance_batch_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
@@ -346,6 +347,29 @@ class FIFOSlots:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _rebalance_batch_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_batch_no"):
self._rebalance_batch_slot_values(item_dict["fifo_queue"])
def _rebalance_batch_slot_values(self, fifo_queue: list) -> None:
"""A batch is one valuation pool, so per-slot value differences are stale
detail: spread the pool value over its slots in proportion to qty."""
groups = {}
for slot in fifo_queue:
if is_batch_slot(slot):
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
groups.setdefault(key, []).append(slot)
for slots in groups.values():
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
if total_qty <= 0:
continue
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})

View File

@@ -5,7 +5,13 @@ from unittest.mock import patch
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
from erpnext.stock.report.stock_ageing.stock_ageing import (
BATCH_SLOT_QTY_INDEX,
BATCH_SLOT_VALUE_INDEX,
FIFOSlots,
format_report_data,
get_average_age,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -565,10 +571,11 @@ class TestStockAgeing(ERPNextTestSuite):
],
)
def test_partial_batch_reco_keeps_existing_slot_values(self):
def test_partial_batch_reco_pools_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
The reco entry qty (delta 2) does not cover the whole batch, so
stock_value_difference / qty is not the batch rate: skip the rescale."""
stock_value_difference / qty is not the batch rate: skip the rescale.
The batch total (1400) is untouched, then pooled across both slots."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
@@ -608,11 +615,163 @@ class TestStockAgeing(ERPNextTestSuite):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
[slot[:4] for slot in queue],
[
[batch_no, 1, 10.0, "2021-12-01"],
[batch_no, 1, 2.0, "2021-12-01"],
],
)
self.assertAlmostEqual(queue[0][4], 1166.67, places=2)
self.assertAlmostEqual(queue[1][4], 233.33, places=2)
def test_batch_receipts_at_differing_rates_pool_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue.
Nothing goes negative, but the batch is one valuation pool, so both
age slots carry the pooled rate instead of their receipt value."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Split",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-SPLIT-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
[batch_no, 1, 2.0, "2021-12-01", 400.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)
def test_batch_pooling_preserves_total_on_repeating_rate(self):
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
The pooled rate does not terminate, so assert the redistributed
slot values still add back to the batch total."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Residual",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-RESIDUAL-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 3, 3, 100),
make_sle("2021-12-02", "002", 6, 9, 0),
make_sle("2021-12-03", "003", 2, 11, 0),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0])
self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0)
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
Consuming the zero-valued head slot at the pooled rate drives it
negative; slot values are then rebalanced to the batch pool rate."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Rebalance",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-REBALANCE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
make_sle("2021-12-03", "003", -4, 16, -20),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 6.0, "2021-12-01", 30.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)

View File

@@ -136,7 +136,7 @@ frappe.query_reports["Stock Balance"] = {
fieldname: "include_zero_stock_items",
label: __("Include Zero Stock Items"),
fieldtype: "Check",
default: 0,
default: 1,
},
{
fieldname: "show_dimension_wise_stock",

View File

@@ -497,7 +497,9 @@ class update_entries_after:
self.data = frappe._dict()
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
"cancelled"
):
self.initialize_previous_data(self.args)
self.build()
@@ -805,10 +807,23 @@ class update_entries_after:
def process_sle_against_current_timestamp(self):
sl_entries = get_sle_against_current_voucher(self.args)
if self.args.get("cancelled") and sl_entries:
self.seed_previous_sle_for_cancellation(sl_entries[0])
for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle)
def seed_previous_sle_for_cancellation(self, anchor_sle):
key = (anchor_sle.item_code, anchor_sle.warehouse)
if key in self.prev_sle_dict:
return
args = frappe._dict(anchor_sle)
args["sle_id"] = args.name
prev_sle = get_previous_sle_of_current_voucher(args)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
def get_future_entries_to_fix(self):
# includes current entry!
args = self.data[self.args.warehouse].previous_sle or frappe._dict(

View File

@@ -336,6 +336,85 @@ class TestSubcontractingOrder(ERPNextTestSuite):
bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract
)
def test_close_subcontracting_order_releases_reserved_qty(self):
# RM in stock at the reserve warehouse for transfer
make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100)
make_stock_entry(
target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100
)
bin_before_sco = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname="reserved_qty_for_sub_contract",
as_dict=1,
)
# Create SCO with a reserve warehouse on the supplied items
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 100,
"fg_item": "_Test FG Item",
"fg_item_qty": 10,
},
]
sco = get_subcontracting_order(service_items=service_items)
# Transfer only 90% of the raw materials to the supplier warehouse
ste = frappe.get_doc(make_rm_stock_entry(sco.name))
for item in ste.items:
item.qty *= 0.9
ste.save()
ste.submit()
sco.load_from_db()
self.assertEqual(sco.status, "Partial Material Transferred")
# Receive only a partial qty so the order stays open (per_received < 100)
scr = make_subcontracting_receipt(sco.name)
scr.items[0].qty -= 1
scr.save()
scr.submit()
sco.load_from_db()
self.assertEqual(sco.status, "Partially Received")
# Keep another SCO open so transfers from the closed SCO must not reduce its reservation
open_sco = get_subcontracting_order(service_items=service_items)
self.assertEqual(open_sco.status, "Open")
bin_before_close = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
as_dict=1,
)
# One unit remains reserved for the partially transferred SCO, plus ten for the open SCO
self.assertEqual(
bin_before_close.reserved_qty_for_sub_contract,
bin_before_sco.reserved_qty_for_sub_contract + 11,
)
# Close the partially-received order
sco.update_status("Closed")
self.assertEqual(sco.status, "Closed")
bin_after_close = frappe.db.get_value(
"Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
as_dict=1,
)
# Closing releases the remaining unit without applying its transfer against the open SCO
self.assertEqual(
bin_after_close.reserved_qty_for_sub_contract,
bin_before_sco.reserved_qty_for_sub_contract + 10,
)
self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1)
def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self):
"""A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be
able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the