* test: cover both repost branches and the no-repost case
* fix: queue repost for entries backdated by a concurrent submit
---------
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
make_bundle_for_material_transfer squares stock_value_difference for
outward rows instead of negating it. multiply by -1, matching the qty
negation on the line above.
no behaviour change: set_incoming_rate and calculate_qty_and_amount both
recompute the field from qty * incoming_rate before the bundle is saved.
the total of allocated_percentage was compared to 100 with exact float
equality, so a correct allocation could be rejected when the sum drifts
in binary floating point (10.0 + 58.02 + 31.98 -> 100.00000000000001).
round the total to the field precision before comparing, in both
SellingController.calculate_contribution and Customer.validate.
* feat(accounts): split exchange gain and exchange loss accounts
Add optional Exchange Gain Account and Exchange Loss Account fields on
Company. When set, realized FX gain/loss from settling an invoice in a
foreign currency (via Payment Entry, Payment Reconciliation, or a
Journal-Entry-based advance) books to the matching account instead of
the single Exchange Gain/Loss account. Either field left blank falls
back to the existing Exchange Gain/Loss account, so companies that
don't configure the new fields are unaffected.
New companies get "Exchange Gain" and "Exchange Loss" accounts
auto-created in their chart of accounts and auto-assigned to the new
fields, same as the existing Exchange Gain/Loss account provisioning.
The Payment Reconciliation tool's per-allocation "Difference Account"
override in its reconcile dialog continues to work as before; the
split accounts only change the computed default shown there.
* test(account_balance): account for new Exchange Gain account in income report
The new auto-provisioned Exchange Gain account under Indirect Income
now shows up in the Income root type report for _Test Company 2.
---------
Co-authored-by: test <test@test.com>
Greptile flagged that the sales-side zero-qty-return fix had no dedicated
test proving the behavior - the existing suite happened to pass, but
nothing specifically asserted that an all-zero return is rejected while
a normal negative-qty return still succeeds.
Adds two tests covering the doctypes that rely entirely on this check
(no other guard covers them for a non-stock-effect return):
- Delivery Note return with qty 0 -> rejected
- Sales Invoice return with qty 0 (no update_stock) -> rejected
POS Invoice is not covered separately here since it always runs with
update_stock=1, which is already guarded by the pre-existing
validate_zero_qty_for_return_invoices_with_stock check regardless of
this fix.
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Sales Invoice, Delivery Note, or POS Invoice return be submitted with
every line at qty=0 - a no-op document with no stock or financial effect
that still consumed a document number and linked back to the original
transaction.
Scoped to the Sales side only: items_returned now flips to True for
Sales Invoice/Delivery Note/POS Invoice only when qty (or received_qty)
is actually negative, so an all-zero sales return correctly hits the
existing "At least one item should be entered with negative quantity"
check. Purchase Invoice, Purchase Receipt, and Subcontracting Receipt
are unchanged.
* fix(controllers): source trend report labels from the master
item_name, customer_name, territory and supplier_name are stored on each
transaction and editable, so they are not functionally dependent on the grouped
key and historical documents can hold different values for the same item,
customer or supplier. Aggregating them with Max() is a text sort, and MariaDB
folds case while PostgreSQL orders by byte value, so the two engines can label
the same row differently.
Read each from its master instead. Those values ARE dependent on the grouped
key, so they can be grouped without splitting rows and agree on both engines by
construction rather than by an assumption about the data. Supplier needed no new
join -- the Supplier master was already joined as t3 for supplier_group.
A Quotation's party_name is a dynamic link to either a Customer or a Lead, so
neither master can be joined without dropping the other; there the values come
from correlated subqueries over both, keyed only on the grouped party_name.
Row counts and every numeric total are unchanged. What changes is that a
renamed record now shows its current name rather than whichever historical
snapshot happened to sort highest.
* test(selling): assert which label the trends report returns
The existing tests assert the customer stays one row but never which territory
or name comes back, so a divergence between engines passes unnoticed. Asserts
both equal the Customer master's values while an order stores a different
territory.
* fix(controllers): resolve a Quotation's party label through quotation_to
party_name is a dynamic link, so looking it up in Customer and Lead alone was
wrong twice over: a Quotation raised against a Prospect or a CRM Deal got a
blank label, and when a Lead shared its name with a Customer the Customer-first
lookup returned the wrong record's name and territory.
Resolve through the quotation_to discriminator instead, mirroring
Quotation.set_customer_name -- Customer, Lead (company_name falling back to
lead_name), Prospect, and CRM Deal. The CRM Deal branch is emitted only when its
table exists, since it ships with the CRM app.
quotation_to joins the GROUP BY as well: two parties of different types can
share a name, and merging them into one row was never right.
* style(controllers): name the quotation CASE branches
semgrep's string-concat-in-list flags adjacent string literals inside a list,
since that shape is usually a missing comma rather than deliberate. Bind each
branch to a name first so the concatenation is unambiguous.
The existing query tests assert only how many rows come back, so an ordering
divergence between engines passes unnoticed. Adds a case-adversarial pair: a
lead whose name starts with the search term in upper case, and one containing
it in lower case later on. The first must rank ahead of the second.
Reapplies #56330, which was reverted by #56389 with no recorded reason and has
been absent since 23 June.
The search filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so
a candidate matches regardless of case. The ranking used a bare Locate(), which
frappe renders as strpos() -- case-sensitive there. A candidate can therefore
pass the filter, score no match in the ranking, fall back to 99999 and sort
last, while MariaDB's case-insensitive LOCATE ranks it first.
Same query, different order on the two engines, and a different result page
once page_len cuts between them.
Lower() both operands, matching the item, project, user and pick list handlers
in this same file, which were already correct.
calculate_item_values rounds every Float field on an item row to the
site's Float Precision (3 by default), and conversion_factor was one of
them. The factor is a ratio, not a rate: UOM Conversion Factor.value is
stored at precision 9, and Material Request keeps the full value because
it has no currency field and so never runs the calculation.
Mapping a Material Request to a Purchase Order therefore truncated the
factor - 0.453592292 for Pound -> Kg became 0.454 - and stock_qty, which
is recomputed as qty * conversion_factor, drifted from the quantity that
was requested, leaving the Material Request unable to close.
Exclude conversion_factor from the rounded fields on the server and on
the client. Factors below the site precision would otherwise round to
zero outright.
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Purchase Invoice, Purchase Receipt, or Subcontracting Receipt return be
submitted with every line at qty=0 - a no-op document with no stock or
financial effect that still consumed a document number and linked back
to the original transaction.
Scoped to the Purchase side only: items_returned now flips to True for
Purchase Invoice/Purchase Receipt/Subcontracting Receipt only when qty
(or received_qty) is actually negative, so an all-zero purchase return
correctly hits the existing "At least one item should be entered with
negative quantity" check. Sales Invoice, Delivery Note, and POS Invoice
are unchanged.
Also applies a corresponding check to the item_name-only fallback branch
(for rows without an item_code - Item Code is not mandatory on Purchase
Invoice Item), which previously bypassed this fix entirely and still set
items_returned=True unconditionally regardless of quantity. For that
branch specifically, only qty is checked (not received_qty): with no
linked Item there's no accepted/rejected split, so received_qty carries
no independent meaning and a qty=0 row must be rejected regardless of
its value.
Default Warehouse and Sample Retention Warehouse were global singles, so every
consumer had to re-check that the warehouse belonged to the transaction's
company before using it. Both now live on Company, under a new Warehouse
Defaults section that also collects the warehouse fields Company already had.
The company check moves to Company.validate_warehouses, which also rejects
group warehouses for all seven fields — a group or cross-company value there
already failed at SLE time, this just surfaces it at the source.
New companies get their Stores warehouse as Default Warehouse via
create_default_warehouses, replacing the setup-wizard and test-fixture code
that seeded the global.
Native JSON request bodies deliver target_doc as a parsed dict, which the
str | Document type hints rejected, breaking every Get Items From button.
Unify all whitelisted mapper endpoint hints to str | dict | Document | None.
Needs frappe#41190 so get_mapped_doc converts the dict target.
Company.create_default_departments named and looked up the root
Department via _("All Departments"), which resolves in the session
language. A site set up in a non-English language stores the root
translated, and a company created later from a session in another
language misses it and inserts a second root, corrupting the tree.
Resolve the root once with get_root_of (falling back to the canonical
English name on fresh installs) and reuse it for the root record, the
exists-guard and the child departments' parent.
Also stop translating lookups of records install_fixtures stores under
English names: Price List "Standard Selling" and Print Headings
"Credit Note" / "Debit Note".
Same class as the root Item Group fix (#57386, issue #57345).
test@example.com carries System Manager in the frappe fixtures, so on a
fresh CI site it can read Delivery Note and the lookup legitimately
returns the draft. test1@example.com has no roles, making the
no-permission assertion environment-independent.
Restrict to Companies only filtered list views and document reads, and
only for users with Company user permissions. Any user could still use
a master restricted to Company A in a Company B transaction, and users
without Company user permissions bypassed the feature entirely.
Validate on save of transactions that every linked Item, Customer and
Supplier allows the transaction company, and filter item link queries
by the transaction company so restricted items don't show up in the
item selector.
frappe.get_list defaults to 20 rows; child-table joins can produce
duplicate parent names that fill the window and hide further drafts.
Also clarify the check-ordering regression test.
When creating a follow-up document (SO->DN, PO->PR, PI->Payment Entry,
etc.), warn the user if a draft of the target doctype already linked to
the source document exists, with links to the drafts and the option to
proceed anyway.
The target doctype comes from the make_mapped_doc response via the new
frappe.model.add_mapped_doc_guard hook, so every open_mapped_doc flow is
covered without per-doctype code or method-name inference. The server
lookup walks parent-level and child-table Link / Dynamic Link fields of
the target doctype and queries through frappe.get_list, so role and user
permissions apply and docstatus filtering happens in the query itself.
Payment Entry creation bypasses open_mapped_doc, so its controller runs
the same guard explicitly.
* feat: book Expenses Added To Stock GL entries for Stock Entry, Stock Reconciliation and LCV
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: make stock expense GL booking configurable via Accounts Settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: skip stock expense booking for unconfigured companies, check flag once per compose
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#56871 routed any searchfield without a DocField meta — including
"name", which get_search_fields() always appends — into the
`= cint(txt)` branch. For non-numeric search text cint() yields 0, so
`is_group = 0` (Cost Center) matched every leaf record on both
engines, and `name = 0` matched every non-numeric name on MariaDB.
Skip Check fields from or_filters entirely — a checkbox can't match
search text — and keep LIKE for everything else, including "name".
Continues the AccountsController service decomposition (Phase 5).
- Add accounts/services/deferred_accounting.py with DeferredAccountingService
owning the deferred revenue/expense validations (income/expense account
defaulting and service start/end date checks).
- Move the document-schedule orchestration (validate_all_documents_schedule
and the invoice/non-invoice variants) into PaymentScheduleService, where
they already delegated, removing the controller-to-service round trip.
- Update the three validate() call sites; keep
validate_auto_repeat_subscription_dates on the controller (still called by
buying/selling controllers).
No behavior change. accounts_controller.py 1818 -> 1745 lines.