Compare commits

...

234 Commits

Author SHA1 Message Date
Nikhil Kothari
5b2952aa23 chore: update deps in banking app (#57971) 2026-08-10 09:45:11 +00:00
Diptanil Saha
ef1d83c298 fix: field validation and perm checks on get_stock_reservation_entries_for_voucher (#57968) 2026-08-10 09:18:53 +00:00
Shllokkk
a25decfa50 fix: skip incoming rate calc when serial no qty is zero (#57427) 2026-08-10 11:54:49 +05:30
Pandiyan P
399ff463cc fix: re-check future sle before queuing repost on submit (#57664)
* 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>
2026-08-10 11:53:49 +05:30
Khushi Rawat
d618ad24f1 Merge pull request #57822 from Shllokkk/asset-repair-downtime-recalc
fix: keep asset repair downtime in sync with entered dates
2026-08-10 11:50:22 +05:30
Diptanil Saha
e342bf765e fix: escape customer_details on lead creation from appointment (#57947) 2026-08-09 19:14:33 +00:00
Shllokkk
5486fbff03 Merge pull request #57927 from Shllokkk/sales-register-ledger-pos-paid
fix: reflect in-invoice receivable credits in Sales Register ledger view
2026-08-09 23:39:10 +05:30
Shllokkk
45a9294476 test: cover POS-paid invoice in Sales Register ledger view 2026-08-09 20:22:14 +05:30
Shllokkk
40c356d166 fix: reflect in-invoice receivable settlements in Sales Register ledger view 2026-08-09 20:22:12 +05:30
Mihir Kandoi
fe09f45ca7 Merge pull request #57895 from mihir-kandoi/fix-track-sfg-finished-good
fix: track semi finished goods production flow
2026-08-09 20:11:05 +05:30
Mihir Kandoi
0bb322f4d1 Merge pull request #57560 from nishkagosalia/gh-56632
fix: handling negative grand total
2026-08-09 16:45:13 +05:30
Mihir Kandoi
bf13284137 Merge pull request #57917 from mihir-kandoi/fix/italy-customer-name-fields
fix(regional): rename Italy's duplicate Customer name fields
2026-08-09 16:44:54 +05:30
Mihir Kandoi
4089f138f2 fix(controllers): correct negative rate settings link 2026-08-09 16:29:28 +05:30
Mihir Kandoi
110d0a38a6 fix(regional): rename Italy's duplicate Customer name fields
The Italy regional setup created Custom Fields first_name/last_name on
Customer. Since #46281 added standard quick-entry fields with the same
names, every Italian site carries duplicate field definitions:

- the setup wizard creates the duplicates silently because it skips
  validation, and any later Custom Field on Customer then raises
  UniqueFieldnameError (#50915)
- without the duplicates, creating an Italian company aborts inside
  install_country_fixtures; on MariaDB an interrupted fixture run
  persists Custom Field documents whose columns were never added, after
  which every Company insert fails with "Unknown column
  'fiscal_regime'" (#57215)

Re-land the rename from #50921 (reverted in #53409): the fields become
italy_customer_first_name/italy_customer_last_name and the e-invoice
template reads the new names. The migration patch runs only on sites
with Italy fixtures, re-runs them, explicitly syncs the schema of every
affected doctype (create_custom_fields skips unchanged fields, so its
own schema sync cannot restore missing columns), copies the old column
values wherever the new field is empty (also on sites that removed the
duplicate fields with the documented manual workaround), and deletes
the duplicate Custom Fields last so an interrupted run stays resumable.

The old insert_after anchor "salutation" no longer exists on Customer;
the renamed fields anchor after customer_type.
2026-08-09 16:29:17 +05:30
Mihir Kandoi
7d1d5a1309 Merge pull request #57918 from mihir-kandoi/fix/qi-reference-notify-update
fix: sync open reference forms after Quality Inspection submit
2026-08-09 16:04:39 +05:30
Mihir Kandoi
e8a6884d5e test: doc_update published for reference on Quality Inspection submit 2026-08-09 15:53:37 +05:30
Mihir Kandoi
647452c95b fix: sync open reference forms after Quality Inspection updates them
update_qc_reference() writes the QI link and bumps the reference
document's modified timestamp via raw db writes, which emit no realtime
event. A reference form (Purchase Receipt, Delivery Note, Stock Entry,
Job Card) still open in the browser keeps the old timestamp and fails
the timestamp conflict check on the next save/submit, forcing a manual
refresh after every QI submit/cancel/delete.

Calling notify_update() on the reference publishes the standard
doc_update event, so an open, unedited form silently reloads and syncs
its timestamp. get_lazy_doc skips child table loading since
notify_update only needs the parent row.
2026-08-09 15:45:07 +05:30
MochaMind
40d59f9a15 chore: update POT file (#57915) 2026-08-09 10:02:29 +00:00
Mihir Kandoi
4a31772f3d Merge pull request #57914 from aerele/fix/outward-bundle-sign
fix: negate stock value difference for outward transfer bundles
2026-08-09 14:37:06 +05:30
pandiyan
eff29565ae fix: negate stock value difference for outward transfer bundles
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.
2026-08-09 13:20:48 +05:30
Mihir Kandoi
691c341edd Merge pull request #57911 from aerele/fix/sales-team-allocated-percentage-float
fix: tolerate floating-point drift in sales team allocated percentage
2026-08-09 12:28:56 +05:30
pandiyan
4afba94d1c test: sales team allocation totalling 100 in floating point
covers the case where the percentages are correct but the accumulated
sum is 100.00000000000001. two rows can never drift, since the second
reconstructs exactly as 100 - first, so the case needs three rows.
2026-08-09 11:25:24 +05:30
pandiyan
f7b2775829 fix: tolerate floating-point drift in sales team allocated percentage
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.
2026-08-09 11:25:12 +05:30
pandiyan
24de81f9fa test: work order process loss for semi finished goods
Cover both shapes: a single operation that books the loss itself, and a
chain where an earlier operation books it and the final operation loses
nothing, so the sum over the operations is the only correct source.
2026-08-09 09:45:49 +05:30
pandiyan
0eb61c9fac fix: roll up process loss to the work order for semi finished goods
update_work_order_qty() returns early when track_semi_finished_goods is
enabled, so set_process_loss_qty() never ran and Work Order.process_loss_qty
stayed at zero even though the job cards and the work order operations had
booked the loss. The work order also never reached the Completed status,
since that needs produced_qty + process_loss_qty to cover the ordered qty.

Calling set_process_loss_qty() from that early return is not enough: the
final operation has no semi finished good bom, so its manufacture entry is
not from a bom, remove_fg_completed_qty() zeroes fg_completed_qty and
update_work_order_qty() is never reached at all.

The manufacture entries cannot be summed either. Each one is reset to
MAX(Work Order Operation.process_loss_qty), so every entry of a multi
operation chain carries the running maximum instead of the loss of its own
operation. Aggregate the operations instead, and refresh the work order from
the job card, which is where the operation loss is written.
2026-08-09 09:45:49 +05:30
Mihir Kandoi
529a003ed1 Merge pull request #57832 from nishkagosalia/st-75115
fix: validate webform for project
2026-08-09 09:41:24 +05:30
Nishka Gosalia
53d3ba7a78 Merge pull request #57903 from SuhasBharadwajK/develop
fix: condition check with empty object for falsy case
2026-08-09 00:18:36 +05:30
Diptanil Saha
13d3101f12 fix: set restrict_globals=True in frappe.render_template (#57899) 2026-08-08 23:40:58 +05:30
nishkagosalia
126966d1db fix: validate webform for project 2026-08-08 23:20:14 +05:30
rohitwaghchaure
ca0a5cb67c feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report (#57863)
* feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report

* fix: pick last bundle move in SQL ordered by posting datetime and SLE creation

* fix: derive synced serial no status from stock ledger helper and validate sync args
2026-08-08 13:48:13 +00:00
Mihir Kandoi
1478e2a4cb test: transfer qty exemption only applies when material transfer is skipped 2026-08-08 17:37:42 +05:30
Mihir Kandoi
1deae664ce fix: keep the transfer qty check for legacy semi FG cards without an FG item
Existing submitted BOMs may carry operations without a finished good,
and no migration repairs them. Exempting every semi FG job card from
the transfer check let such a card submit after a partial transfer.
Exempt only cards that skip material transfer; legacy cards with
transfer enabled keep the strict transferred qty check.
2026-08-08 17:37:42 +05:30
Mihir Kandoi
8f0617c834 test: partial entries consume exactly the job card's material requirement 2026-08-08 17:37:42 +05:30
Mihir Kandoi
0428cddf5b fix: scale generated raw materials to the manufacture entry's production share
Every generated entry copied each Job Card Item's full required_qty in
the skip-transfer and BOM-backflush paths, so two entries for one job
card consumed the requirement twice. Scale the rows to the share of
production this entry accounts for and cap them at the requirement
still unconsumed, dropping rows that have nothing left. An entry whose
materials are exhausted then fails the existing at-least-one-raw-material
check instead of minting finished goods from nothing.
2026-08-08 17:37:42 +05:30
Mihir Kandoi
424a1dfa87 test: update-after-submit save keeps the manufacture entry intact 2026-08-08 17:37:42 +05:30
Mihir Kandoi
bed957fa67 fix: skip the pending production check on update-after-submit saves
Saving a submitted manufacture entry to change an allowed field re-ran
the pending production cap with a manufactured aggregate that already
includes the entry itself, so the save was rejected against the
post-entry remainder. Quantities are not editable after submit, so the
check has nothing to protect there.
2026-08-08 17:37:42 +05:30
Mihir Kandoi
db99657c47 test: target warehouse stays optional for semi FG work orders 2026-08-08 17:37:42 +05:30
Mihir Kandoi
9df527bf3f fix: keep Target Warehouse optional for work orders tracking semi finished goods
The WIP warehouse change also removed the Target Warehouse exemption
for semi FG orders, but those may validly carry the target on each
operation instead. Restore the exemption in the form and the submit
check; the WIP warehouse requirement stays.
2026-08-08 17:37:42 +05:30
Mihir Kandoi
eb7537c8df test: partial manufacture entry then finishing the job card 2026-08-08 17:37:42 +05:30
Mihir Kandoi
b8dd886cd4 fix: generate the next manufacture entry net of booked process loss
After a partial entry booked the job card's full process loss, the
next generated entry was sized qty-to-produce minus manufactured only.
It exceeded the pending production cap, so Make Stock Entry could not
finish the card. Subtract the consumed loss when sizing the entry.
2026-08-08 17:37:03 +05:30
Mihir Kandoi
7157e4357b test: stale manufacture draft cannot over-produce without an operation BOM 2026-08-08 17:37:03 +05:30
Mihir Kandoi
94cd27ce5d fix: cap a manufacture entry at the job card's pending production
Entries from operations without their own BOM carry no For Quantity,
so the finished-good reconciliation cannot run for them and a draft
created before other entries were submitted could still over-produce.

Validate every job-card manufacture entry against the job card
directly: finished goods plus process loss must fit in what the job
card still has left to produce after earlier submitted entries.
2026-08-08 17:30:55 +05:30
Mihir Kandoi
9ef386dfd2 test: operation BOM materials expand on single-pass submit, final FG must match the BOM item 2026-08-08 17:29:59 +05:30
Mihir Kandoi
1e2e87daac fix: derive operation FG items before material expansion, keep the final one the BOM's item
The finished_good derivation ran in validate_semi_finished_goods,
after set_materials_based_on_operation_bom had already expanded
operation BOM materials. A single-pass insert-and-submit (API or
import) with bom_no set but finished_good empty skipped the expansion,
persisting a submitted BOM without the referenced components. The
derivation also let a final operation inherit another item from its
bom_no, so downstream job cards would produce the wrong item.

Move the derivation into set_operation_finished_goods, called before
the expansion, prefer the BOM's own item for the final operation, and
reject a final operation whose FG item is not the BOM's item.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
0aec62a8dd test: raw material dialog adds a row for its operation despite duplicates 2026-08-08 17:29:59 +05:30
Mihir Kandoi
24f1f3dea8 fix: add raw material to its operation even when another operation uses the item
get_item_details returns the whole Item document, so the dialog row's
name became the item code. get_item_data then matched that item code
against every Components row regardless of operation, so adding an item
already used by another operation silently updated that row's qty
instead of appending one for the target operation — which stayed empty
and failed 'please add raw materials or set a BOM' on submit.

Match the existing row by item code within the same operation: same
operation updates the qty, any other match appends a new row.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
5e0f056284 test: manufacture entry keeps process loss scoped to its own operation 2026-08-08 17:29:59 +05:30
Mihir Kandoi
1b335973b7 fix: scope manufacture entry process loss to its own job card
set_process_loss_qty stamped MAX(process_loss_qty) across every
operation of the work order onto each manufacture entry. With semi
finished goods tracking, one operation's process loss leaked into the
entries of every other operation: validate_fg_completed_qty then
rejected the entry when it had a BOM, or the wrong loss was recorded
silently when it did not, double-counting the loss across operations.

When the entry belongs to a job card, use that job card's loss net of
what its earlier entries already booked. The MAX fallback stays for
work-order level entries without a job card.

Fixes frappe/erpnext#57892
2026-08-08 17:29:59 +05:30
Mihir Kandoi
335dbdaca4 test: previous operation shortfall from process loss gets the right message 2026-08-08 17:29:59 +05:30
Mihir Kandoi
1e22695eae fix: stop asking for a manufacturing entry when process loss explains the shortfall
When a previous operation manufactured less than the current job card
is completing, the error always said 'Submit the manufacturing entry
for the operation first' — even when the entry was already submitted
and the missing quantity was booked as process loss, which made the
advice a dead end.

Sum the process loss of the previous operation's job cards alongside
the manufactured quantity. When manufactured + process loss covers the
requested quantity, say the shortfall is process loss so the user
knows to reduce the completed quantity; keep the submit-first message
for genuinely pending manufacturing entries.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
f61f6523b9 test: WIP warehouse required for work orders tracking semi finished goods 2026-08-08 17:29:59 +05:30
Mihir Kandoi
198eb60df7 fix: require WIP warehouse for work orders tracking semi finished goods
Work orders with track_semi_finished_goods were exempt from the
Work-in-Progress Warehouse requirement in three places: the field's
mandatory_depends_on, the fg_warehouse reqd toggle in the form script,
and validate_warehouse on submit.

The exemption was misleading. The flow still transfers materials to a
WIP warehouse when 'Skip Material Transfer' is unchecked: operations
default their WIP warehouse from the work order, and
set_default_warehouse silently restores the company default after the
user clears the field. Make the field genuinely required instead of
pretending it is optional.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
4b3904c6d7 test: semi FG job card is exempt from the legacy transfer qty check 2026-08-08 17:29:59 +05:30
Mihir Kandoi
6c8f0b9b56 fix: don't demand raw material transfer for semi FG job cards on submit
validate_transfer_qty uses an empty finished_good to detect legacy job
cards, and unlike validate_semi_finished_goods it ignores
skip_material_transfer. A job card tracking semi finished goods whose
operation had no finished_good fell into the legacy branch and could
not be submitted even with 'Skip Material Transfer' checked on the
work order.

Return early for semi FG job cards; validate_semi_finished_goods
already enforces the transfer requirement for them and honours
skip_material_transfer.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
aed7c70b1c test: BOM tracking semi finished goods rejects operations without FG item 2026-08-08 17:29:59 +05:30
Mihir Kandoi
3497a6a6bf fix: require FG / Semi FG Item on operations when tracking semi finished goods
A BOM with track_semi_finished_goods enabled could be saved with no
finished_good on any operation: validate_semi_finished_goods only
checked that one row had 'Is Final Finished Good' set, and a list
containing None passed the emptiness check.

Such a BOM breaks every downstream step. The work order copies the
empty finished_good into its operations, job cards inherit it, and
Make Stock Entry finally fails with 'Item None not found' because the
manufacture entry has no production item.

Derive the finished good where it is unambiguous: an operation that
references a BOM produces that BOM's item, and the final operation
produces the BOM's own item. Otherwise require it on the row, since
each operation's job card books its output through it.
2026-08-08 17:29:59 +05:30
Mihir Kandoi
11a902eb5f Merge pull request #57887 from mihir-kandoi/fix-repost-ma-return-sibling-rate
fix: repost read stale sibling SLE rate for moving average returns
2026-08-08 12:02:24 +05:30
Mihir Kandoi
b3f97cd389 fix: incorrect entry detection in Stock Ledger Invariant Check (#57886) 2026-08-08 05:31:21 +00:00
Mihir Kandoi
97d41867b5 fix: zero-rate repost fallback could still read sibling SLE
When the in-memory running rate is zero, the fallback went through
get_incoming_rate, whose previous-SLE lookup matches the same
posting_datetime and can land on a sibling line of the voucher being
replayed. Replace it with get_previous_sle_of_current_voucher excluding
the current voucher, keeping the get_valuation_rate chain when no
previous entry exists. get_incoming_rate is no longer used in this
module.
2026-08-08 11:00:47 +05:30
Mihir Kandoi
1a47c61b67 test: repost of multi-line moving average return is idempotent
Reposting a return that removes most of the stock across several lines
of the same item must keep every line at the running average and produce
identical results on a second repost. Before the fix the first repost
already drifted, seeding each line from a sibling row of the same
voucher.
2026-08-08 10:53:04 +05:30
Mihir Kandoi
b589a4d335 fix: repost read stale sibling SLE rate for moving average returns
During repost, a return line with recalculate_rate resolved its moving
average rate through get_incoming_rate -> get_previous_sle, which matches
posting_datetime <= and orders by creation desc. For a multi-line return
of the same item, every line shares one posting_datetime, so the query
landed on a sibling line of the same voucher whose stored valuation_rate
was still the previous repost run's output, not the rate before the
voucher.

Each repost run therefore re-seeded the voucher from its own prior
output. The error gain per run is (qty returned at the stale rate) /
(qty remaining after the return), so whenever a return removes most of
the stock the loop diverges instead of converging, alternating sign and
growing until stock_value overflows decimal(21,9) and the repost dies
with 'Out of range value for column stock_value'.

Use the in-memory running valuation rate that update_entries_after
already tracks for the warehouse at this point in the repost. It is the
authoritative pre-entry state, is immune to sibling rows, and makes the
repost idempotent. The database lookup is kept only as a fallback for a
zero in-memory rate, preserving the existing zero-rate fallback chain.
2026-08-08 10:51:13 +05:30
Pandiyan P
55fe269046 fix: allow selecting a warehouse for new items in the update items dialog (#57876) 2026-08-07 17:32:29 +00:00
Mihir Kandoi
5822cef1d9 feat(manufacturing): prompt for qty when creating material request from work order (#57855) 2026-08-07 17:25:51 +00:00
Mihir Kandoi
5e372df533 Merge pull request #57883 from mihir-kandoi/fix-pp-min-order-qty-round-up
fix: order smallest purchase UOM qty that meets min order qty
2026-08-07 22:49:55 +05:30
Mihir Kandoi
1ca06024cb test: marginal min order qty overage raises a toast 2026-08-07 22:36:02 +05:30
Mihir Kandoi
1aa6f8d1a7 feat: warn when ordered qty exceeds min order qty only by UOM rounding
A minimum order qty defined in stock UOM often has no exact
representation in the purchase UOM, so the smallest valid order slightly
exceeds the minimum. Surface that overage on the Purchase Order with a
toast on first save when an item's ordered stock qty is above its
minimum by less than one purchase-UOM step, so the buyer sees the
marginal increase before sending the order. Sub-precision dust stays
silent.
2026-08-07 22:36:02 +05:30
Mihir Kandoi
1b2090820a Merge pull request #57873 from mihir-kandoi/fix-conversion-factor-precision
fix: declare precision 9 on all conversion_factor fields
2026-08-07 22:20:57 +05:30
Mihir Kandoi
e4b66dc731 test: min order qty conversion takes the grid ceiling
Covers both rounding brackets, an exactly representable conversion, the
no-minimum path, and the ceiling through the plan items and materials
from other locations flows.
2026-08-07 22:20:38 +05:30
Mihir Kandoi
ee8eb18daf fix: order smallest purchase UOM qty that meets min order qty
A Production Plan with Consider Minimum Order Qty raises the requirement
to the item's minimum in stock UOM, then converts it to the purchase UOM
with round-to-nearest. Nearest rounding can land below the minimum it
just applied: min order qty 50000 with purchase UOM conversion factor
453.592292197 becomes 110.231, which is 49999.932 in stock UOM, and the
mapped Purchase Order is then rejected by validate_minimum_order_qty.

When the minimum binds and the nearest-rounded value dips below it,
quantize to the smallest representable purchase-UOM quantity whose stock
equivalent meets the minimum, using Decimal grid-ceiling arithmetic.
110.232 converts to 50000.386: demand stays as planned and the overage
is order-unit granularity, the standard MRP lot-sizing outcome. Ordinary
conversions keep the historical round-to-nearest behavior.
2026-08-07 22:20:30 +05:30
Mihir Kandoi
594ff24dae Merge pull request #57879 from mihir-kandoi/remove-dead-mr-po-mapper
refactor: remove unused make_purchase_order_based_on_supplier
2026-08-07 21:03:07 +05:30
Shllokkk
67f4b7a483 Merge pull request #57880 from Shllokkk/guard-clear-reconciliation-patch
fix: guard reconciliation table deletes when tables are missing
2026-08-07 20:03:19 +05:30
Shllokkk
8a2b2a2b68 fix: guard reconciliation table deletes when tables are missing 2026-08-07 19:33:45 +05:30
Mihir Kandoi
8b2946ca6b refactor: remove unused make_purchase_order_based_on_supplier
Its only caller, Purchase Order's get_items_from_open_material_requests,
was deleted in 91e9867fb1 (refactor: Cleanup buying module forms). The
old dotted path was already broken by the move to mapper.py, so no
external caller can be using it either.
2026-08-07 18:42:00 +05:30
Mihir Kandoi
ca5a673409 fix: round computed conversion factors to field precision
The inverse (1 / value) and intermediate-UOM branches of
get_uom_conv_factor returned raw float quotients like
0.4535922921968971, bypassing the precision the docfields now declare.
Same for the client-side back-calculation from an edited stock qty.
Round both to the UOM Conversion Factor value precision.
2026-08-07 17:47:50 +05:30
Mihir Kandoi
69a35a12cb fix: declare precision 9 on all conversion_factor fields
The Float control parses values with the field precision, falling back
to the global float precision when the docfield declares none
(frappe ControlFloat.parse / get_precision). On a site with float
precision 2, a fetched UOM factor of 0.453592292 was written back to
the model as 0.45, silently corrupting every derived quantity by 0.8
percent. A ratio must not inherit display precision meant for
quantities, so declare the same precision 9 the UOM Conversion Factor
master already uses on every transaction-level conversion_factor
field.
2026-08-07 17:31:32 +05:30
Mihir Kandoi
b3300c20a3 Merge pull request #57866 from krishna-254/fix/product-bundle-item-search
fix: optimize product bundle item search
2026-08-07 16:02:51 +05:30
Mihir Kandoi
4e9b069c20 Merge pull request #57861 from mihir-kandoi/fix-uom-integer-check-dust
fix: UOM whole number check truncated instead of rounding
2026-08-07 14:03:26 +05:30
Mihir Kandoi
e6a6458ebe test: UOM whole number check tolerates conversion dust 2026-08-07 13:49:00 +05:30
Mihir Kandoi
a464a6e4a1 fix: UOM whole number check truncated instead of rounding
cint truncates, so a stock_qty of 1999.9998 (dust from qty times
conversion factor) compared as abs(1999 - 2000.0) > epsilon and was
rejected as fractional even though it rounds to a whole number at
field precision, with the error confusingly printing the rounded
value: 'Quantity (2000.0) cannot be a fraction'. Round to field
precision first, then require the result to be a whole number.
Dust above an integer already passed; this fixes the asymmetry for
dust below.
2026-08-07 13:48:38 +05:30
Mihir Kandoi
a219f890df Merge pull request #57859 from mihir-kandoi/fix-po-min-order-qty-precision
fix: compare ordered qty to min order qty at stock_qty precision
2026-08-07 13:46:55 +05:30
Krishna Shirsath
b3867f1428 fix: optimize product bundle item search 2026-08-07 13:44:57 +05:30
Mihir Kandoi
c652f47931 test: min order qty check tolerates UOM conversion dust 2026-08-07 12:51:40 +05:30
Nishka Gosalia
d9e048ece7 Merge pull request #57845 from nishkagosalia/settings-mapping-cleanup
chore: restructure exported files of doctype settings
2026-08-07 12:46:43 +05:30
Mihir Kandoi
98b7407949 fix: compare ordered qty to min order qty at stock_qty precision
stock_qty is stored as raw qty * conversion_factor, so a UOM-converted
order for exactly the minimum (e.g. LB to Kg) produces values like
1999.999999131832 vs a min_order_qty of 2000 and blocks the Purchase
Order. Round both sides to the stock_qty field precision before
comparing, and show the rounded qty in the error message.
2026-08-07 12:44:28 +05:30
Mihir Kandoi
3f8b263014 Merge pull request #57854 from mihir-kandoi/fix/work-order-create-button-group
fix(UX): group work order actions under the Create menu
2026-08-07 04:22:33 +05:30
MochaMind
523d0e4312 fix: sync translations from crowdin (#57842) 2026-08-06 20:03:30 +02:00
Mihir Kandoi
780ab3ba3e fix(UX): group work order actions under the Create menu
Pick List, Material Request, Material Consumption and Additional
Material Transfer were spread across two standalone buttons and a
separate Make menu. Put them all under a single Create menu, and rename
Create Pick List to Pick List since the menu already says Create.
custom_make_buttons is updated to the new label so the connections
shortcut still finds the button.
2026-08-06 23:02:05 +05:30
Mihir Kandoi
e1815cb5a9 Merge pull request #57851 from mihir-kandoi/fix-production-plan-mr-qty-precision
fix: round Production Plan mr_items quantity to field precision
2026-08-06 21:11:10 +05:30
Mihir Kandoi
75145cc72c test: remaining purchase qty is rounded to field precision
Covers the _add_remaining_purchase_request path: partial stock in
another warehouse is allocated as a transfer and the residual purchase
qty goes through the second rounding site.
2026-08-06 20:59:04 +05:30
Mihir Kandoi
f5157bf3c4 test: mr_items quantity is rounded to field precision 2026-08-06 20:49:42 +05:30
Mihir Kandoi
ffc515f046 fix: round production plan mr_items quantity to field precision
The stock-UOM qty is rounded in _accumulate_so_items, but the purchase
UOM conversion divided it by the conversion factor without re-rounding,
storing values like 5738748.300863984 in mr_items.quantity. The raw
value flowed into Material Request qty and the raw materials CSV, and
make_material_request compares quantity to requested_qty with exact
float equality, so any rounding downstream left dust quantities.
2026-08-06 20:48:42 +05:30
Mihir Kandoi
44260b469f refactor: remove unreachable UOM conversion in production plan
The division by conversion_factor in _adjust_required_qty_for_uom sits
directly after frappe.throw inside the same block, so it can never run.
It has been dead since commit 2a8cd05b44 (#27278) re-indented it into
the throw branch; the actual purchase-UOM conversion happens in
_material_request_item_row via _mr_purchase_conversion_factor.
2026-08-06 20:48:19 +05:30
Jatin3128
96a6db7387 feat(accounts): split exchange gain and exchange loss accounts (#57839)
* 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>
2026-08-06 17:36:43 +05:30
Suhas Bharadwaj
e0b9351d49 fix: condition check with empty object for falsy case 2026-08-06 16:38:46 +05:30
nishkagosalia
8096766d71 chore: restructure exported files of doctype settings 2026-08-06 16:28:18 +05:30
rohitwaghchaure
a49fcfe888 fix: purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (#57835)
* fix: use current batch avg rate for outward returns of batchwise valuation batches

* fix: honor zero batch average and avoid duplicate batch classification query
2026-08-06 15:21:44 +05:30
Diptanil Saha
12359c36bc Merge pull request #57825 from diptanilsaha/st/72599/arpbmd/pinv
refactor(accounts)!: rework Purchase Invoice hold actions and enforce them on Journal Entry
2026-08-06 13:11:51 +05:30
diptanilsaha
1a8d438b21 test(journal_entry): added test cases for blocked purchase invoices 2026-08-06 12:31:00 +05:30
diptanilsaha
cbafa16fbc fix(journal_entry): validate blocked purchase invoices 2026-08-06 11:56:58 +05:30
diptanilsaha
6c33ede45c refactor(purchase_invoice): expose invoice hold actions as document methods 2026-08-06 11:18:46 +05:30
rohitwaghchaure
742e0e16cf fix: XSS through unescaped work order name in shop floor view (#57826) 2026-08-06 08:53:46 +05:30
Shllokkk
8269f8a362 test: assert asset repair downtime recalculates on date change 2026-08-06 00:33:26 +05:30
Shllokkk
4406bb9068 fix: keep asset repair downtime in sync with entered dates 2026-08-06 00:33:24 +05:30
MochaMind
1ffcfeb11b fix: sync translations from crowdin (#57743) 2026-08-05 15:13:07 +02:00
Henil Maru
8e8ef1602e fix(sales-invoice): respect Customize Form hidden setting on Update Stock (#57818)
set_dynamic_labels() unconditionally forced update_stock's hidden
property based only on is_debit_note/has_subcontracted, overwriting
whatever Customize Form had set on every refresh. OR it with the
field's original (property-setter-driven) hidden value instead.
2026-08-05 18:00:26 +05:30
rohitwaghchaure
d71fc3b774 feat: validate stock value and stock closing entry before period closing (#57811)
* feat: validate stock value and stock closing entry before period closing

* fix: do not accept scoped stock closing entries as period closing prerequisite

* feat: seed batch valuation from stock closing balance and freeze closed-period stock
2026-08-05 15:45:16 +05:30
Mihir Kandoi
8aadffa73c Merge pull request #57810 from aerele/fix/blanket-order-mapped-naming-series
fix: do not copy Blanket Order naming series to the mapped order
2026-08-05 14:12:11 +05:30
pandiyan
7620553418 test: assert mapped order keeps its own naming series 2026-08-05 13:03:10 +05:30
pandiyan
fe7128f02f fix: do not copy blanket order naming series to the mapped order
get_mapped_doc copies every same-named field that is not no_copy, so the
Sales Order / Purchase Order / Quotation created from a Blanket Order
inherited MFG-BLR-.YYYY.- and was named MFG-BLR-2026-00003 instead of
SAL-ORD-2026-00001.

exclude naming_series from the mapping, same as job card does when it
maps to a Purchase Order.
2026-08-05 13:03:10 +05:30
Mihir Kandoi
1f42eb1a3c Merge pull request #57793 from aerele/fix/blanket-order-zero-qty-validation
fix: validate Blanket Order item quantity is greater than zero
2026-08-05 12:11:48 +05:30
rohitwaghchaure
d3a8c329dd fix: incorrect batch-wise valuation rate for entries with same posting datetime (#57803)
fix: incorrect batch-wise valuation rate for entries with same posting datetime (#57794)

* fix: incorrect batch-wise valuation rate for entries with same posting datetime

The tie-breaker in get_batch_no_ledgers compared the bundle's creation
against the SLE's creation. These are different timelines - a bundle can
be created (drafted) much before its SLE (created at submission). For
entries sharing a posting datetime (backdated / amended vouchers), this
mis-ordered the entries against the ledger's replay order (SLE creation),
causing double counting or omission of batch qty / value and runaway
outgoing rates that no repost could heal.

Now the tie is broken using the creation of the bundle's own SLE (same
timeline on both sides). When the valuation runs through the bundle
before its SLE exists, the entry is by definition last in its timestamp
group, so all same-timestamp entries already in the ledger precede it.



* test: batch-wise valuation ordering for same posting datetime entries

Covers both tie-breaking branches of get_batch_no_ledgers:
- submission (pre-insertion) branch: same-timestamp inward at a different
  rate plus a multi-row outward voucher (same item and warehouse), at
  submission and after a backdated repost
- existing-SLE branch: a bundle created after its sibling's SLE, the
  ordering must follow the SLE creation and not the bundle creation

Both tests fail with the previous parent.creation < sle.creation
tie-breaker and pass with the fix.



---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:06:30 +05:30
Diptanil Saha
4d511a1521 chore(CODEOWNERS): add @nikkothari22 for banking (#57801) 2026-08-04 19:53:54 +00:00
Diptanil Saha
fc8e2e8627 Merge pull request #57734 from aerele/fix/payment-reconciliation-supplier-gain-loss-sign
fix(payment reconciliation): correct supplier gain/loss posting
2026-08-04 23:55:38 +05:30
Sudharsanan Ashok
0dbe410414 fix(stock): handle multi-item opening balance in Stock Ledger report (#57591)
* fix(stock): handle multi-item opening balance in Stock

* test(stock): add unit test for multi-item Stock Ledger report

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-08-04 22:14:16 +05:30
R-Jayaraman
d80b0f67cc test: add regression test for zero quantity Blanket Order 2026-08-04 19:06:25 +05:30
R-Jayaraman
e897c4d82d fix: validate Blanket Order item quantity is greater than zero 2026-08-04 19:05:51 +05:30
Shllokkk
b9dafafeee Merge pull request #57790 from Shllokkk/warehouse-account-override-value-comparison
test: child warehouse account override in stock vs account value comparison
2026-08-04 17:30:28 +05:30
Shllokkk
ef7a3cb4c8 test: child warehouse account override excluded in stock vs account value comparison 2026-08-04 17:03:25 +05:30
Mihir Kandoi
afdb951eb4 Merge pull request #57757 from aerele/fix/opportunity-qty-validation
fix(opportunity): add validation for positive item quantities
2026-08-04 16:52:55 +05:30
R-Jayaraman
69de8f2d62 chore: use flt() in qty check 2026-08-04 16:38:49 +05:30
Mihir Kandoi
8b710ddbf1 Merge pull request #57772 from aerele/fix/party-dashboard-doctype-permission
fix(accounts): skip party dashboard without invoice permission
2026-08-04 16:33:30 +05:30
Jatin3128
0f428ed854 fix(subscription): don't reactivate a cancelled subscription (#57774)
* fix(subscription): don't reactivate a cancelled subscription

set_subscription_status() unconditionally set status to Active once
there was no outstanding invoice, even if the subscription had been
intentionally cancelled. Paying off an invoice issued before
cancellation (directly, or via the Payment Entry -> refresh hook)
flipped a Cancelled subscription back to Active while cancelation_date
stayed set.

process()'s cancel_at_period_end check compared posting_date against
getdate(self.end_date), and getdate(None) returns today, so an empty
end_date was silently treated as "cancel now" on every scheduler run.
Combined with the reactivation bug, this let a cancelled subscription
toggle Cancelled -> Active on each run and generate another invoice at
the next period boundary.

Fixes #57761

* fix(test): compare normalized dates in subscription cancellation test

cancelation_date read straight off an unsaved in-memory doc is a
string from nowdate(), but the same field comes back as a
datetime.date after reload(). Wrap both sides in getdate() so the
comparison isn't type-sensitive.
2026-08-04 15:59:44 +05:30
Mihir Kandoi
3dd01e5120 Merge pull request #57777 from mihir-kandoi/fix/bom-creator-toolbar-actions
fix(manufacturing): reach the whole configurator from tree toolbar actions
2026-08-04 15:38:28 +05:30
Mihir Kandoi
097ce0f348 fix(manufacturing): reach the whole configurator from tree toolbar actions
The toolbar handlers were copied onto view.events as unbound functions, so
`this` inside them was that object literal rather than the BOMConfigurator.
They worked only because the literal also carried `frm`, and broke as soon as
a handler called a method the literal did not list: get_item_code, added when
the tree started keying nodes on the row name, threw
"this.get_item_code is not a function" and killed Add Raw Material, Add Sub
Assembly and Convert to Sub Assembly.

Assign the instance instead of a hand-maintained whitelist. Every method is
reachable, `this.frm` keeps working, and no future method can be forgotten.

Fixes #57773
2026-08-04 15:34:59 +05:30
Diptanil Saha
9ce32fc1da fix(sales_invoice): enable repost on account change of account in payments (#57775) 2026-08-04 09:52:28 +00:00
Sudharsanan11
ed78dd37be fix(accounts): skip party dashboard without invoice permission 2026-08-04 14:10:55 +05:30
Jatin3128
c1717d8689 fix: keep source rate on re-fetch when maintain same rate is enabled (#57479)
* fix: keep source rate on re-fetch when maintain same rate is enabled

With "maintain same rate" on, re-fetching item details on a row mapped from a
source document (e.g. a Purchase Order) pulled the latest Item Price, giving a
rate the document can never be saved with. Skip the price list fetch for such
rows and keep the source rate.

Fixes frappe/erpnext#57436

* fix: keep source rate on bulk apply_price_list when maintain same rate is on

The single-row re-fetch guard skipped the bulk apply_price_list path, so
changing the price list, party, or conversion rate on a mapped transaction
re-fetched current Item Prices and overwrote the mapped rates, breaking the
maintain-same-rate check on save.

Guard apply_price_list_on_item with the same source-row lookup, and resolve the
parent doctype via ctx.parenttype since the bulk path carries the child doctype
in ctx.doctype.

* fix: preserve full source pricing on rate-locked rows

Restoring only price_list_rate on a mapped row dropped any manual discount or
margin, so re-running pricing produced a rate that differed from the source and
still failed the maintain-same-rate check on save.

Copy the source row's whole pricing block (rate, discount, margin) and skip
pricing rules for locked rows, in both get_item_details and the bulk
apply_price_list path.

* fix: pass child_docname in server bulk price apply so the rate lock is reachable

The server-side _apply_price_list builds its item ctx from as_dict(), which omits
the child_docname key the desk (JS) callers add, so the maintain-same-rate lock in
apply_price_list could not match rows in that path. Pass child_docname for
consistency with the desk callers.

* test: cover maintain-same-rate preservation on re-fetch of a discounted row

Reproduces the end-to-end symptom: a mapped Purchase Receipt row with a source
discount (rate != price_list_rate) keeps its rate after a re-fetch, so the
document saves under maintain-same-rate. Covers percentage and amount discounts
via process_item_selection, the server recompute the desk mirrors.

* fix: read the locked rate from the persisted source row

get_rate_locked_source_row returned the mutable target row, so an unsaved rate or
discount edit on a mapped row was preserved on re-fetch instead of the source
pricing, and the document still failed maintain-same-rate on save. Read the
pricing straight from the linked source row in the database, and cover the
edit-then-refresh case with a test.

* fix: permission-check the source row before returning its rate

The rate lock reads the linked source row with a direct db.get_value, which
bypasses permissions on a whitelisted endpoint. Only return the source pricing
when the caller can read the source document, so a crafted request cannot
disclose another document's rate. Covered by a test.

* fix: import make_purchase_receipt from its current mapper module

make_purchase_receipt moved from purchase_order.py to
purchase_order/mapper.py in a develop refactor pulled in by this
branch's merge commit. Two tests added afterwards still imported it
from the old path, failing CI with an ImportError.

* fix: Simplify source retrieval logic in get_item_details

Removed permission check for source parent in get_item_details.py.

* Revert "fix: Simplify source retrieval logic in get_item_details"

This reverts commit 58863805bd.
2026-08-04 13:28:57 +05:30
Khushi Rawat
8cefaa355c Merge pull request #57539 from harisansari008/fix/dunning-timestamp-mismatch-multi-installment
fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments
2026-08-04 13:02:44 +05:30
Diptanil Saha
4255df05cd Merge pull request #57742 from diptanilsaha/fix/escape_data_in_templates
fix: escape data in multiple templates
2026-08-04 12:11:43 +05:30
ruthra kumar
bca3889f97 Merge pull request #57719 from krishna-254/fix/reversal-journal-entry-custom-remark
Fix/reversal journal entry custom remark
2026-08-04 10:59:46 +05:30
Krishna Shirsath
5e0e9ba668 fix: allow custom remark on reversal journal entry 2026-08-03 17:16:09 +05:30
Mihir Kandoi
e8b16a4228 Merge pull request #57754 from mihir-kandoi/fix/isolate-disabled-attribute-test-fixtures
test: isolate the disabled item attribute test fixtures
2026-08-03 17:01:24 +05:30
Mihir Kandoi
ae6749470f test(stock): isolate the disabled attribute fixtures
The test disabled the shared `Test Size` Item Attribute. On version-15
`FrappeTestCase` rolls back once per class instead of once per test, so the
flag stayed visible for the rest of `TestItem` and broke the seven tests that
build a variant from that attribute.

Build a dedicated attribute and template instead. Nothing the test writes is
reachable from another test, on either branch, so no cleanup is needed.
2026-08-03 16:43:07 +05:30
R-Jayaraman
c47cc37441 fix(opportunity): add validation for positive item quantities 2026-08-03 16:41:43 +05:30
Mihir Kandoi
8db8c6a83d fix(stock): allocate secondary item cost from the consumption entry (#57738)
* fix(stock): allocate secondary item cost from the consumption entry

A secondary item's rate is its BOM share of the cost of the consumed
rows. With Get RM Cost From Consumption Entry enabled the consumption
happens in a separate document, so the Manufacture entry carries no
consumed rows and that cost is zero. The share evaluated to zero, and the
row fell through to the item's own valuation rate.

Only the finished good substituted the consumption entry's cost. Against
a consumption entry of 1000 and a BOM allocating 75% to the finished good
and 25% to scrap, the finished good took its 750 while the scrap took an
unrelated valuation of 100, booking 850 for 1000 consumed.

Derive the allocation base once and use it for both sides.

* test(stock): cover secondary allocation against a consumption entry

A consumption entry of 1000 splits into 750 and 250 by the BOM's shares.
2026-08-03 10:58:23 +00:00
Mihir Kandoi
aef69202ea Merge pull request #57747 from mihir-kandoi/fix/disabled-attribute-blocks-variant-edits
fix: disabled item attribute blocks unrelated edits to existing variants
2026-08-03 16:16:24 +05:30
diptanilsaha
3df596d84e fix: escape data on stock_summary_template 2026-08-03 16:16:07 +05:30
diptanilsaha
10c439ff01 fix: escape item name and item title on item_selector 2026-08-03 16:16:07 +05:30
diptanilsaha
2d387002d9 fix(workstation): escape data on get_workstations 2026-08-03 16:15:59 +05:30
Mihir Kandoi
7d901ed92c fix(stock): treat a 0% BOM cost allocation as no cost (#57736)
* fix(stock): treat a 0% BOM cost allocation as no cost

A BOM splits its raw material cost between the finished good and its
secondary items, and validate_total_cost_allocation holds the two to
100%. An allocation of 0% therefore means the finished good takes
everything and the secondary item carries no cost.

The code read it as no allocation at all. A cost_allocation_per of 0 is
falsy, so the branch was skipped, the row kept a rate of zero, and the
fallback below handed it the item's own valuation rate. Producing 1000 of
raw material into a finished good at 100% and scrap at 0% booked 1000 to
the finished good and another 100 to the scrap.

Apply the BOM's share whatever it is, and mark the rate as derived so the
valuation fallback leaves a deliberate zero alone. rate_derived_from_consumption
becomes has_derived_rate, since it now guards more than the consumption case.

* test(stock): cover a secondary item allocated 0% of the cost

The finished good takes the full 1000 and the scrap row is worth nothing.
2026-08-03 10:42:24 +00:00
Mihir Kandoi
8d5326196e test(stock): cover editing a variant whose attribute is disabled
Assert that a variant saves after its attribute is disabled when the edit
leaves the attribute rows alone, and that changing an attribute value still
throws.
2026-08-03 16:00:27 +05:30
Mihir Kandoi
25cd793617 fix(stock): validate only the variant attributes that changed
Disabling an Item Attribute writes `disabled = 1` into every Item Variant
Attribute row, including the rows on the template. `validate_variant` runs
on every save and walks the whole attribute table, so any later save of an
existing variant re-checked its untouched rows against the now-disabled
template row and threw. `update_variants` hit the same wall, which made a
single template save fail once an attribute was disabled.

The flag exists to keep an attribute out of new variants, not to freeze the
variants that already use it. item.js only reads it to drop the attribute
from the variant creation dialog.

Skip rows that are unchanged since the last save. New and edited rows are
still checked, so a disabled attribute cannot be added to an existing
variant, and the same guard covers the sibling checks for attributes and
values that the template no longer offers.
2026-08-03 15:57:35 +05:30
Mihir Kandoi
7886bd2cab fix(stock): stop treating a Repack secondary item as a finished good (#57735)
* fix(stock): stop treating a Repack secondary item as a finished good

mark_finished_and_secondary_items flagged every incoming Repack row as a
finished item, secondary rows included. Two things followed from that.
The row never reached the secondary-item branch in _set_incoming_item_rate,
so its own cost_allocation_per was never applied, and the BOM's
finished-good percentage was applied to every incoming row rather than
to the finished good alone.

Value was destroyed as a result. Repacking 1000 of raw material under a
BOM that allocates 75% to the finished good and 25% to scrap booked 500
to the finished good and 250 to the scrap: 750 in against 1000 out.

Leave secondary rows unflagged so each side takes the share the BOM
declares.

* test(stock): cover cost allocation for a Repack secondary item

A BOM allocating 75% to the finished good and 25% to scrap must split
1000 of raw material into 750 and 250, leaving no difference.
2026-08-03 10:19:09 +00:00
Mihir Kandoi
be3df759f1 Merge pull request #57732 from mihir-kandoi/fix/secondary-item-without-bom-balances-manufacture
fix(stock): cost a BOM-less secondary item out of the finished good
2026-08-03 15:22:16 +05:30
Mihir Kandoi
7f47361ebd test(stock): cover a secondary item added without a BOM
A Manufacture entry with a raw material worth 1000, a finished good and a
Scrap row typed in the UI must value the scrap at its own rate and take
that value out of the finished good, leaving no difference.
2026-08-03 15:01:08 +05:30
Mihir Kandoi
5e81cd1540 fix(stock): cost a BOM-less secondary item out of the finished good
The legacy scrap checkbox deducted the scrap row's value from the
finished good, so a Manufacture entry balanced. Its replacement, the
Secondary Item Type dropdown, only balances when the row carries a BOM
Secondary Item link, because the cost allocation percentage lives there.
A row typed as Scrap in the UI has no such link, so its value was added
on top of a finished good that already absorbed the whole raw material
cost, and the entry closed with a non-zero difference.

Treat a secondary row with no BOM link the way the legacy scrap item was
treated: deduct its value from the finished good.

The finished good's rate is derived from the other incoming rows, so
those rows must be rated first. Previously the finished good was rated
in row order, ahead of the secondary rows, and picked up their amounts
only on a later validate pass. Rate the finished goods last so a single
pass is correct.
2026-08-03 15:00:41 +05:30
Mihir Kandoi
33ea059018 Merge pull request #57737 from mihir-kandoi/fix/secondary-item-must-not-waive-quality-inspection
fix(stock): stop a secondary item type from waiving quality inspection
2026-08-03 14:59:13 +05:30
Nikhil Kothari
abc3da6b97 fix(banking): fetch company list from DB instead of boot (#57731)
* fix(banking): fetch company list from DB instead of boot

* fix: show error banner for company list fail fetch
2026-08-03 14:11:36 +05:30
Mihir Kandoi
fec5dae639 test(stock): cover inspection on a receipt row typed as a secondary item
The row must be blocked with or without the type set.
2026-08-03 13:55:27 +05:30
Mihir Kandoi
dfec7bd5c7 fix(stock): stop a secondary item type from waiving quality inspection
The inspection skip for secondary rows applied to every purpose, and in
validate_inspection it skipped the row even when the item itself mandated
inspection. Secondary Item Type is only meaningful on the purposes that
produce secondary items, but nothing clears it elsewhere, since
mark_finished_and_secondary_items runs for Manufacture and Repack alone.

A Material Receipt of an item marked Inspection Required Before Purchase
is blocked without an inspection. Setting Secondary Item Type on the row
submitted it clean.

Limit the exemption to the purposes that produce secondary items, and to
other doctypes such as Subcontracting Receipt, which carry the field with
its intended meaning. The client-side mirror is kept in sync.
2026-08-03 13:55:26 +05:30
Sudharsanan11
5442ad4c48 test(payment reconciliation): cover supplier exchange gain posting 2026-08-03 13:40:45 +05:30
Sudharsanan11
4688ddd217 fix(payment reconciliation): correct supplier gain/loss posting 2026-08-03 13:40:45 +05:30
Raffael Meyer
915eef0355 ci: hide eo.po from translation PR review details (#57200) 2026-08-03 07:38:11 +00:00
Mihir Kandoi
947f1b148c Merge pull request #57647 from aerele/fix/sales-zero-qty-return-validation
fix(sales): reject sales returns where every item has zero quantity
2026-08-03 12:56:02 +05:30
R-Jayaraman
732c884633 test(sales): add coverage for zero-qty return rejection
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.
2026-08-03 12:41:38 +05:30
R-Jayaraman
a3e9d13da3 fix(sales): reject sales returns where every item has zero quantity
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.
2026-08-03 12:40:57 +05:30
Mihir Kandoi
b4d73cd934 Merge pull request #57725 from aerele/fix/stock-over-delivery-role-scope
fix(stock): scope over deliver/receive role check to delivery and receipt overflow
2026-08-03 12:39:08 +05:30
Afsal Syed
99630f40eb test(stock): prevent settings leakage in purchase order tests 2026-08-03 12:27:33 +05:30
Afsal Syed
0b271e24b6 test(stock): add test cases verifying stock over delivery role does not bypass order allowance 2026-08-03 12:27:33 +05:30
Afsal Syed
248873034d fix(stock): scope over deliver/receive role check to delivery and receipt overflow 2026-08-03 12:27:33 +05:30
Afsal Syed
446ec6030a fix(stock): validate over delivery/receipt allowance in stock settings 2026-08-03 12:27:33 +05:30
Mihir Kandoi
c020de5a69 Merge pull request #57097 from aerele/fix/qi-reading-number-format
fix(stock): read quality inspection readings in the user's number format
2026-08-03 12:26:18 +05:30
Mihir Kandoi
f03c1311cd fix(controllers): source trend report labels from the master (#57724)
* 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.
2026-08-03 06:52:25 +00:00
Mihir Kandoi
8154c45bf0 fix(accounts): key the payment ledger CTEs on account, not Max(account) (#57720)
* fix(accounts): key the payment ledger CTEs on account, not Max(account)

QueryPaymentLedger builds two CTEs -- voucher amount and outstanding -- and
joins them on account among other columns. Both sides selected Max(account)
while grouping without it, so the join key was an aggregate over two different
row sets. A voucher posting ledger entries against two party accounts could
have the two sides pick different accounts, the join miss, and the outstanding
come back NULL. Max() over text is a sort, so which account wins is also
collation-dependent, and the engines sort text differently.

Group both CTEs by account instead. That makes the join key a real column and
scopes each Sum() to a single account -- so amount_in_account_currency is no
longer summed across accounts that may not share a currency. Row shape only
changes for a voucher that genuinely spans two party accounts for one party,
where today's single row is already an arbitrary pick over mixed currencies.

cost_center and remarks stay descriptive but genuinely vary per entry, and were
aggregated independently, so they could be stitched together from different
entries into a row that was never posted. They now come off one real entry,
picked by Min(name) -- Payment Ledger Entry declares no autoname rule, so
frappe names it by hash, and those are lower-case, which keeps the pick free of
the collation divergence.

* test(accounts): cover payment ledger metadata coherence

A Journal Entry posting two receivable lines for one customer with different
cost centers and remarks. Whatever row the ledger returns, its cost center and
remarks must be a pair that was actually posted. Guards the fixture itself, so
it cannot pass by posting only one distinct pair.

* test(accounts): cover the account-keyed payment ledger aggregation

The coherence test posts both party lines to one account, so it exercises the
representative-row metadata but not the account-keyed grouping or the CTE join.
Adds a Journal Entry posting to two receivable accounts for one customer and
asserts each account comes back as its own row, with its own amount and a
non-null outstanding.
2026-08-03 06:46:04 +00:00
Mihir Kandoi
00d17ca5db test(stock): cover reading number formats end to end
Set the number format on the session user rather than on System Settings: the
code reads the user default, which shadows the global one, so these tests never
exercised the path they were written for. Restoring it in a finally also keeps
a failed assertion from leaving the whole suite in another locale.

Add a table test over every format in NUMBER_FORMAT_MAP, covering the grouped
values and the three formats parse_float used to read as 0, and restore the
formula-based coverage for non-numeric readings.
2026-08-03 12:14:19 +05:30
Mihir Kandoi
5b5f354090 fix(stock): accept every number a reading can be written as
parse_float and is_valid_number each re-derived the number grammar, so the
validator accepted strings flt() cannot parse: str.isdigit() lets superscripts
through and lstrip("+-") lets repeated signs through, both then silently scored
as 0. One parse_reading() returning None when float() refuses the value makes
acceptance and conversion true by construction.

The grammar was also wrong for several formats. Where the group separator is
not a dot, a dot-decimal reading such as 1.15 parsed correctly before and is
accepted again. #,### and #.### report no decimal separator at all, which
rejected every fractional reading outright and, for #.###, reread a stored
1.500 as 1500.0; they now fall back to a dot and give up the grouping that
would collide with it.

Only readings that change are checked, so an inspection entered by a user in
one locale stays saveable and submittable by a user in another, and manual
inspection rows keep the free text they were never parsed for.

NumberFormat replaces get_number_format_info, which frappe drops in v16.
2026-08-03 12:14:19 +05:30
Sudharsanan11
b1f188146e test(stock): cover quality inspection readings in every number format
covers the reported case, a 1,15 reading in the space grouped "# ###,##"
format, which was read as 115 and rejected. also covers the dot grouped
comma format, and asserts that a reading written with the wrong separator,
or one that is not a number at all, is now rejected with an error rather
than read as a different value.
2026-08-03 12:14:19 +05:30
Sudharsanan11
3752be809f test(stock): drop non numeric reading from formula based quality inspection
a numeric reading of "random text" was read as 0 and pulled the mean from
0.6 down to 0.4, which the test then asserted as accepted. such a reading
is now rejected outright, and the test is about formula evaluation, so drop
the row. its assertions are unchanged.
2026-08-03 12:14:19 +05:30
Sudharsanan11
e74c0a3cdb fix(stock): read quality inspection readings in the user's number format
readings are Data fields, so they are parsed server side. parse_float only
swapped the separators for "#.###,##", so in the space grouped "# ###,##"
(polish) a reading of 1,15 was read as 115, fell outside the acceptance
range and silently rejected the inspection. strip whatever the group
separator is and normalise whatever the decimal separator is instead.

it also read the global number format, while the desk formats numbers with
the user's own. a user whose locale differs from the site therefore typed
readings in a format the server did not parse them with. read the user
default, which falls back to the global one.

a reading that is not a valid number in that format is now rejected with an
error instead of being read as a different number.
2026-08-03 12:14:19 +05:30
Mihir Kandoi
d74add35d4 fix(accounts): take POS summary warehouse and cost centre from one item line (#57723)
* fix(accounts): take POS summary warehouse and cost centre from one item line

Both describe an item line, not the invoice, and an invoice can carry several.
They were aggregated independently per invoice, so the report could show a
warehouse from one line beside a cost centre from another -- a pair that was
never posted.

The warehouse then becomes an outer grouping key, so the pick is not merely a
label: it decides how rows are partitioned across owner/date and therefore what
each row totals. Max() over text is a sort, and MariaDB folds case while
PostgreSQL orders by byte value, so the two engines can partition differently.

Take both off one real line instead, and the mode of payment off one real
payment line for the same reason. Sales Invoice Item is hash-named and Sales
Invoice Payment declares no autoname rule, so frappe hash-names it too -- which
keeps Min(name) free of the collation divergence that sorting text has.

* test(accounts): cover POS summary warehouse/cost-centre coherence

The existing tests post a single item line, so they cannot see this. Adds an
invoice with two lines whose warehouse and cost centre are deliberately
crossed: the higher warehouse sits on the line with the lower cost centre, so
an independently aggregated pair belongs to neither line.

* fix(accounts): pick the POS summary representative by idx, not by hash

Min(name) selected whichever child row happened to have the lowest hash, which
is arbitrary and turns on something unrelated to the data. Min(idx) selects the
first line the user actually entered: an integer, so the pick is free of
collation, and it is meaningful rather than incidental.

The join moves to (parent, idx), which is unique per parent.
2026-08-03 06:42:28 +00:00
Mihir Kandoi
bf869c3426 Merge pull request #57718 from frappe/pg-audit/search-ranking-case-insensitive
fix(controllers): restore case-insensitive employee/lead/bom search ranking
2026-08-03 12:08:33 +05:30
Mihir Kandoi
d44ed5357d Merge pull request #57645 from aerele/fix/purchase-zero-qty-return-validation
fix(purchase): reject purchase returns where every item has zero quan…
2026-08-03 11:45:36 +05:30
Mihir Kandoi
1968f06cc8 test(controllers): assert lead search ranking, not just result count
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.
2026-08-03 11:38:58 +05:30
Mihir Kandoi
a7a14c82da fix(controllers): restore case-insensitive employee/lead/bom search ranking
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.
2026-08-03 11:38:57 +05:30
Mihir Kandoi
282712eec2 Merge pull request #57716 from frappe/pg-audit/collation-representative-lines
fix(postgres): read BOM/SO/MR line columns off one line, not Max()
2026-08-03 09:41:36 +05:30
Mihir Kandoi
c8adf9937b refactor(postgres): memoise representative lines with frappe's request_cache
Three helpers each managed their own dictionary on frappe.local, duplicating
cache lifecycle and key handling. @request_cache does the same thing centrally
and is cleared with the request, so the copies cannot drift apart.

Behaviour is unchanged: the decorator keys on the call arguments, which are the
same tuple each hand-rolled key was built from.
2026-08-03 01:03:11 +05:30
Mihir Kandoi
100d0ee784 test(manufacturing): cover the BOM representative-line pick
A BOM listing one item on two lines, with descriptions and source warehouses
that differ. The second line's description sorts above the first on either
engine, so an aggregated value would win; the row must instead carry the first
line's description together with that same line's warehouse.
2026-08-03 00:56:56 +05:30
Mihir Kandoi
414e6560af fix(postgres): read BOM/SO/MR line columns off one line, not Max()
Max() over a text column is a sort, and the engines sort text differently:
MariaDB's utf8mb4 collations fold case, the CI PostgreSQL orders by byte
value. MAX('abc','ABD') is 'ABD' on MariaDB and 'abc' on PostgreSQL --
confirmed on CI in the probe attached to #56241.

The parity effort wrapped many descriptive columns in Max() on the reasoning
that it returns the value MySQL picked arbitrarily. Where the column is
functionally dependent on the group key that holds and the wrap is a genuine
no-op. Where it genuinely varies -- description, item_name, uom and their
warehouses all describe a LINE, not the item -- it does not: MySQL picked a
row, not a maximum, and the sort now diverges between engines. Aggregating
each column separately can also pair one line's description with another's
warehouse, or a uom with the wrong conversion factor.

Take those columns from a single real line instead, the first by idx.

Only groups built from more than one line need it. Each query now also selects
Count(<line>.name).distinct(), and the representative pass returns immediately
when no group has more than one line -- in that case Max() of a single value
is already exact and collation cannot apply. A BOM with no repeated item
therefore issues no extra query at all, which matters because the explosion
and sub-assembly resolution recurse per sub-BOM. Genuine repeats are memoised
per request.

Sites covered: BOM explosion and sub-item queries, sub-assembly raw materials,
get_bom_items_as_dict, BOM Stock Analysis (both queries), Requested Items to
Order and Receive, Pending SO Items for Purchase Request, and Job Card
secondary items.
2026-08-03 00:56:55 +05:30
Mihir Kandoi
a30f3dde0f Merge pull request #57711 from frappe/pg-audit/purchase-register-add-deduct
fix(accounts): net Add and Deduct tax rows in Purchase Register
2026-08-03 00:52:24 +05:30
Mihir Kandoi
03183fc4d9 fix(stock): take disassembly source columns from one posted line (#57710)
* fix(stock): take disassembly source columns from one posted line

get_items_from_manufacture_stock_entry collapses a work order's Manufacture
entries to one row per item and wrapped fifteen Stock Entry Detail columns in
independent Max() to satisfy Postgres' strict GROUP BY. Those columns describe
a line, not an item, and three sets have to stay together:

  uom only means something beside its conversion_factor
  batch_no and serial_no only beside their warehouse
  is_finished_item decides whether the row is the output or an input

Aggregated separately they can be drawn from different lines. Two Manufacture
entries consuming the same item in Nos and in Box return ("Nos", 5) -- a pair
that was never posted, and one that does not describe the summed quantity.

Keep the sums (and the qty-weighted basic_rate) in the aggregate, and read the
descriptive columns off a single real line: the earliest by Stock Entry
creation then idx. That is what MariaDB returned in practice, it is
deterministic, and it is identical on both engines. Same representative-row
shape already used by BOM Stock Analysis and the sub-assembly queries.

* test(manufacturing): cover disassembly source-row coherence

Two Manufacture entries consume the same raw material in different UOMs, so
the max uom and the max conversion factor come from different lines. Asserts
the returned pair is one that was actually posted. Fails on the previous
per-column Max() with ('Nos', 5.0) not found in {('Nos', 1.0), ('Box', 5.0)}.

* fix(stock): aggregate disassembly quantities in stock UOM
2026-08-03 00:52:24 +05:30
Mihir Kandoi
d5ea0d1f6f fix(manufacturing): stop BOM Stock Analysis inflating both its sums (#57709)
* fix(manufacturing): stop BOM Stock Analysis inflating both its sums

get_bom_data left-joined Bin on item_code alone and then summed over the
result. Bin holds one row per warehouse and BOM Item one row per line, so the
join is a cross product and each SUM counts the other side's rows:

  Sum(qty_consumed_per_unit) x (number of warehouses holding the item)
  Sum(bin.actual_qty)        x (number of BOM lines carrying the item)

A component on two BOM lines, stocked in two warehouses, reported a per-unit
requirement of 10 instead of 5 and available stock of 20 instead of 10 --
wrong on both engines, and wrong in the single-line case too as soon as the
item sits in more than one warehouse.

Aggregate Bin to one row per item_code before joining, so neither sum can see
the other's duplicates. The warehouse filter moves into that subquery; it
previously sat in the outer WHERE against a left-joined column, which
silently made the join inner, so the join is now made inner explicitly when a
warehouse is given to keep items with no bin there excluded as before.

* test(manufacturing): cover the BOM Stock Analysis bin-join cross product

Component on two BOM lines, stocked in two warehouses: the join yields four
rows, so both sums are doubled. Asserts qty_per_unit is the sum of the lines'
own per-unit quantities and actual_qty the real total across warehouses.
Fails on the previous single-query form with 10.0 != 5.0.
2026-08-03 00:52:23 +05:30
Mihir Kandoi
5eabd176f5 fix(manufacturing): compute BOM item amount per line (#57708)
* fix(manufacturing): compute BOM item amount per line

get_bom_items_as_dict groups BOM lines by item_code, so a BOM listing the
same item on more than one line collapses to a single row. The amount column
multiplied the summed quantity by a single line's rate:

    Sum(stock_qty / bom.quantity) * Max(rate) * qty

That is neither line's amount and not their total. The Max() was added to
satisfy Postgres' strict GROUP BY on the assumption that rate is constant per
item, but rate is editable per line.

Fold the rate into the sum so every line contributes its own:

    Sum(stock_qty / bom.quantity * rate) * qty

Identical for the common single-line item, correct for duplicates, and valid
on both engines. Same class as the fix applied to budget_controller's
requested amount.

* test(manufacturing): cover BOM item amount across duplicate lines

A BOM listing the same item twice, once in the stock UOM and once in a UOM
with a conversion factor, gives the two lines different rates (rate is the
valuation rate scaled by the conversion factor). The two lines collapse into
one row in get_bom_items_as_dict, so amount must be the sum of each line's
own qty x rate.

Guards the fixture with an assertion that the two rates actually differ,
so the test cannot pass vacuously. Fails on the previous
Sum(stock_qty) * Max(rate) expression.

* fix(manufacturing): use matching UOM quantity for BOM amount
2026-08-03 00:52:23 +05:30
Mihir Kandoi
8f227ad80e Merge pull request #57715 from frappe/pg-audit/collation-taxonomy
docs(postgres): catalog the collation-dependent text pick
2026-08-02 22:20:20 +05:30
Mihir Kandoi
2c6208ad00 Merge pull request #57714 from frappe/ci/patch-test-stacked-pr-base
ci(patch): fall back to develop when the base ref has no frappe branch
2026-08-02 22:18:50 +05:30
Mihir Kandoi
80ca8b3a25 docs(postgres): catalog the collation-dependent text pick
Max()/Min() over a text column is a sort, and the engines sort text
differently: MariaDB's utf8mb4 collations fold case, PostgreSQL as CI runs it
orders by byte value. MAX('abc','ABD') is 'ABD' on MariaDB and 'abc' on
PostgreSQL, confirmed on CI in the probe attached to #56241.

That makes a Max() over a text column which varies in case within its group a
live parity gap, rather than the arbitrary-pick preservation the wrap is
usually justified as. Where the column is functionally dependent on the group
key it stays a genuine no-op and collation cannot matter, so the rule is
scoped to non-FD columns to keep it a high-precision signal.

Recorded as a fifth second-order trap in the guide and in the Greptile
instructions, including the trap that a local macOS PostgreSQL agrees with
MariaDB here and reports a false all-clear.
2026-08-02 22:08:01 +05:30
Mihir Kandoi
39b6f37a48 ci(patch): use GITHUB_REF instead of rebuilding it from type and name
GITHUB_REF is already the fully qualified ref for both branch and tag events,
so reconstructing refs/heads/$GITHUB_REF_NAME and refs/tags/$GITHUB_REF_NAME
just risks the two drifting apart. Keep the type check, since it still decides
whether the develop fallback applies, and take the ref verbatim.
2026-08-02 22:06:21 +05:30
Mihir Kandoi
28d498012a ci(patch): resolve the frappe ref by type and only fall back for branches
The probe used --heads with a bare name, so it could not describe a tag push
and would have fallen back to develop for one. Resolve a fully qualified ref
from the event instead: the PR base or pushed branch under refs/heads, a tag
under refs/tags, and fail loudly on an unrecognised ref type.

Only branch refs are eligible for the develop fallback. A tag that is absent
from frappe is a real error, not a stacked-PR base, so it still fails.
2026-08-02 22:04:48 +05:30
Mihir Kandoi
ccf54b5881 ci(patch): only fall back when the frappe branch is genuinely absent
The previous `||` treated every fetch failure as a missing branch, so a
transient network or auth error on a base that does exist in frappe would
silently substitute develop and report Patch Test results against the wrong
revision.

Probe with `ls-remote --exit-code` instead: exit 2 means no matching ref, so
fall back; any other non-zero status is a real failure and is re-raised.
2026-08-02 20:45:05 +05:30
Mihir Kandoi
1a83fc516e ci(patch): fall back to develop when the base ref has no frappe branch
The Patch Test fetches the frappe repo using this erpnext PR's base branch
name. For an ordinary PR that is develop, which exists in frappe/frappe. For a
stacked PR the base is an erpnext feature branch with no counterpart there, so
the fetch fails and the step exits 128 before any patch runs:

    fatal: couldn't find remote ref pg-audit/bom-amount-per-line

This affects every stacked PR. It has been latent rather than absent: earlier
stacks passed only because their Patch Test ran while they still targeted
develop, before being retargeted onto the layer below.

Fall back to develop when the base ref does not resolve. Ordinary PRs and
version-branch PRs are unaffected -- their base exists in frappe, so the first
fetch succeeds and the fallback never runs.
2026-08-02 20:26:47 +05:30
MochaMind
78f9be257b chore: update POT file (#57707) 2026-08-02 14:31:32 +02:00
Shllokkk
61b80050d1 Merge pull request #57703 from Shllokkk/create-payment-entries-from-payable-report
feat: validate selection and improve Create Payment Entries dialog
2026-08-02 17:43:17 +05:30
Shllokkk
bb5b71643b feat: show PE count, grand total and draft note in payment dialog 2026-08-02 17:28:51 +05:30
Mihir Kandoi
0b9dd11115 Merge pull request #57699 from mihir-kandoi/codex/fix-shipping-rule-duplicate-taxes
fix: prevent duplicate shipping charges without cost center
2026-08-02 12:18:17 +05:30
Mihir Kandoi
106ecd7120 chore: remove shipping rule comments 2026-08-02 12:03:14 +05:30
Mihir Kandoi
a4134af30b fix: prevent duplicate shipping charges without cost center 2026-08-02 12:01:35 +05:30
Mihir Kandoi
c3cd4f18f2 Merge pull request #57676 from mihir-kandoi/feat/mr-supplier-selection-dialog
feat: select a supplier per item when creating Purchase Orders from a Material Request
2026-08-02 11:55:49 +05:30
Mihir Kandoi
6477709f7c Merge pull request #57674 from mihir-kandoi/fix/uom-conversion-factor-precision
fix: preserve UOM conversion factor precision in transactions
2026-08-02 11:55:13 +05:30
Shllokkk
7fdb768259 feat: validate selection and create draft payment entries synchronously 2026-08-01 19:40:39 +05:30
Mihir Kandoi
07ac4d83ef feat(job_card): print quantities with their stock uom (#57689)
* feat(job_card): carry the stock uom on the job card

Every quantity the job card reports belongs to the item it produces, but the
document had no unit of its own, so messages could only print bare numbers.

Add the Stock UOM field, set from the finished good or the final product, and
backfill the job cards that already exist.

* fix(job_card): print quantities with their unit

A bare 5 in an error says nothing about what was counted. Every message that
reports a quantity now names its unit, taking it from the job card's stock uom,
from the previous operation's finished good when the message compares two
operations, and from the item itself for a raw material transfer.

The completion dialogs read the same unit off the job card.

* refactor(job_card): move the stock uom next to the qty it measures

* fix(job_card): keep the stock uom backfill atomic

Drop the auto commit toggle so the backfill is a single transaction with no
connection flag left behind when it raises, and select the job cards to fill
with an explicit unset filter instead of a value list.
2026-08-01 18:34:31 +05:30
Mihir Kandoi
0ddf72dae9 refactor(job_card): make the completion dialog say what it asks for (#57688)
* refactor(job_card): drop the unused make_finished_good handler

Nothing triggered it and Job Card has no make_finished_good method to call.

* refactor(job_card): make the completion dialog say what it asks for

The dialog qty shares the Qty to Manufacture label with the field on the form
while it means the current cycle only, its title fell back to the generic Enter
Value because frappe.prompt takes four arguments and it was passed five, and
nothing on it stated that the three quantities have to add up.

Name the cycle in the label, title the dialog after the button that opens it,
and describe the split on the fields. Same wording in the shop floor dialog.
2026-08-01 18:34:31 +05:30
Mihir Kandoi
7bffd84482 fix(job_card): reject a completion split that cannot add up (#57687)
* fix(job_card): reject a completion split that cannot add up

The completion dialogs silently dropped a recalculation whose result went
negative, so entering a pending qty larger than what is left of the qty to
manufacture kept the contradiction (3 to manufacture, 3 completed, 2 pending)
and the job card only failed much later, on submission.

Keep the split consistent while it is entered: reset the pending qty when the
qty to manufacture changes, and refuse a completed, pending or process loss qty
that leaves the others negative. complete_job_card validates the same rule, so
the shop floor and the API cannot store a split that will never submit.

Also name the three parts in the submission error instead of calling their sum
the Total Completed Qty, which read as a contradiction of the field itself.

* test(job_card): cover the completion qty split guard
2026-08-01 18:34:30 +05:30
Mihir Kandoi
970039d8ec fix(job_card): leave the pending qty out of the job card's own output (#57686)
* fix(job_card): leave the pending qty out of the job card's own output

Pending qty is the part of a job card handed over to another job card, but the
status and the manufacturing entry still measured the card against its full
for_quantity. A card submitted with 3 completed and 2 pending was stuck at Work
In Progress with no way to change it, and its manufacturing entry was built for
the full 5.

Measure both against for_quantity minus pending qty, so the card reaches To
Manufacture on submission, its manufacturing entry covers the completed qty, and
it is Completed once that qty is manufactured.

* test(job_card): cover a job card completed with a pending qty
2026-08-01 18:34:30 +05:30
Mihir Kandoi
0e1bc58b2e fix(job_card): apply the completion dialog's qty to manufacture (#57685)
* fix(job_card): apply the completion dialog's qty to manufacture

Both the desk dialog and the shop floor session dialog send for_quantity when
completing a job card, but complete_job_card dropped it. Reducing Qty to
Manufacture to 3 on a job card of 5 left for_quantity at 5, so set_process_loss
turned the untouched 2 into process loss on the next save.

The dialog qty covers the current cycle, so add it to the qty already completed
by the earlier cycles of the job card instead of overwriting for_quantity, and
validate the pending qty against the result.

* test(job_card): cover qty to manufacture from the completion dialog

Reducing the dialog qty resizes the job card without inventing process loss, and
a pending qty split across two cycles leaves for_quantity untouched.
2026-08-01 18:34:30 +05:30
Mihir Kandoi
3bd3354152 fix(job_card): require the previous operation to be manufactured (#57684)
* fix(job_card): block next operation until previous operation is manufactured

With track semi finished goods, Work Order Operation completed_qty is set from
the submitted job cards' total completed qty, so a job card of the next
operation could be started and completed even when no Manufacture entry existed
for the previous operation. The semi-finished goods it consumes were never
produced.

Validate the sequence against the qty actually manufactured against the previous
operations' job cards (Manufacture entries / Subcontracting Receipts) when the
work order tracks semi finished goods.

* test(job_card): cover manufactured qty check across previous operations

Work order with operations A and B at sequence 1 and C at sequence 2, tracking
semi finished goods. C stays blocked while A's job card is submitted but its
Manufacture entry is missing, and once A is manufactured for 3, C can only be
completed for 3.
2026-08-01 18:34:29 +05:30
Mihir Kandoi
1a49e73c85 Merge pull request #57681 from Shllokkk/sre-voucher-qty-use-demand
fix: set reservation voucher_qty to voucher demand not reserved qty
2026-08-01 15:42:12 +05:30
Shllokkk
7a97dc3361 test: partial work order reservation records full voucher_qty 2026-08-01 15:29:20 +05:30
Shllokkk
7995bb9960 fix: set reservation voucher_qty to voucher demand not reserved qty 2026-08-01 15:29:20 +05:30
Mihir Kandoi
2e72846670 fix: label the items table in the supplier selection dialog
The grid template always renders its label line, so leaving the table unlabelled
left an empty line hanging above the description.
2026-08-01 09:23:09 +05:30
Mihir Kandoi
44fdf7bea9 fix: keep the bulk supplier field to half the supplier selection dialog
A lone Link field stretched the full width of the dialog, which reads as a
search bar rather than a field. A column break holds it to half.
2026-08-01 09:21:16 +05:30
Mihir Kandoi
e84bf44e51 feat: set one supplier across every item in the supplier selection dialog
A Material Request where few items carry a default supplier meant picking the
same supplier row by row. A Supplier field above the table copies its value
into every row, leaving the exceptions to be corrected by hand.

Both pickers skip suppliers that are disabled or barred from Purchase Orders by
their scorecard standing.
2026-08-01 09:19:49 +05:30
Mihir Kandoi
f0bb70539d fix: warn about existing draft orders before the supplier selection creates more
Creating through the dialog calls the endpoint directly instead of going
through open_mapped_doc, so the draft link guard that every other Create action
runs never fired, and a repeated dialog quietly produced a second set of draft
orders for the same quantity.
2026-08-01 09:16:13 +05:30
Mihir Kandoi
8ffe5ba420 test: reject the same Material Request item twice in one supplier selection 2026-08-01 09:11:37 +05:30
Mihir Kandoi
99d56cc850 fix: reject the same Material Request item twice in one supplier selection
Each row was checked against the pending quantity on its own, so a payload that
listed one item under two suppliers passed both checks and ordered the pending
quantity twice. The dialog cannot produce that, a direct call to the endpoint
can.
2026-08-01 09:11:37 +05:30
Mihir Kandoi
21c6d10ad3 fix: escape item code and UOM in the supplier dialog errors
Desk renders a client side message as HTML, so an Item or UOM whose name holds
markup ran as markup in the buyer's session.
2026-08-01 09:11:37 +05:30
Mihir Kandoi
3856eaa35e fix: open the Purchase Order when the supplier selection creates only one
Naming a single order in a message and leaving the buyer to click it is a step
for nothing. The form opens directly when there is one order; the message stays
for the case it was meant for, several orders at once.
2026-08-01 09:02:05 +05:30
Mihir Kandoi
d233fdf198 test: reject a supplier selection without items 2026-08-01 09:00:12 +05:30
Mihir Kandoi
07445b3675 feat: order only the items ticked in the supplier selection dialog
Every row is ticked when the dialog opens, so the common case of ordering
everything is unchanged, and a buyer who wants a partial order unticks what
should wait. Creating with nothing ticked is rejected.
2026-08-01 09:00:12 +05:30
Mihir Kandoi
5a78e2290a fix: link the item and spell out the unit in the supplier dialog errors
A bare item code left the buyer to find the item themselves, and a bare number
gave no clue what the limit was counted in. Both messages now link the item and
state the pending quantity in bold with its UOM.
2026-08-01 08:57:04 +05:30
Mihir Kandoi
671c289303 test: alert when Required By falls back to today 2026-08-01 08:52:23 +05:30
Mihir Kandoi
53e09dfdd6 feat: alert when Required By falls back to today
Items whose requested date has passed silently got today as Required By, which
is a date the buyer never asked for. A toast now says so.
2026-08-01 08:52:23 +05:30
Mihir Kandoi
d0cae2eb9c feat: show the UOM alongside the quantity in the supplier selection dialog
The quantity is meaningless without the unit it is counted in, which the buyer
had to look up on the Material Request itself.
2026-08-01 08:52:23 +05:30
Mihir Kandoi
6f22551aae fix: list the Purchase Orders created per supplier instead of opening one
Opening one of several created orders hid the rest and moved the buyer off the
Material Request. The created orders are now reported the way Production Plan
reports its documents, as links in a message, and the form stays put.
2026-08-01 08:39:03 +05:30
Mihir Kandoi
15d10bbaf1 test: Required By on Purchase Orders created per supplier
Backdates the Material Request item so the mapper drops its schedule date, and
asserts the created order still saves with today as Required By.
2026-08-01 08:38:42 +05:30
Mihir Kandoi
d05bd80b1e fix: set Required By on Purchase Orders created per supplier
Mapping drops a schedule date that already passed, leaving the buyer to pick a
new one on the Purchase Order form. Nothing fills it in when the orders are
created straight from the supplier selection dialog, so a Material Request
whose required date has gone by failed to save with "Please enter the Required
By".

Items that lose their date now fall back to today, which is the earliest date a
Purchase Order raised today accepts.
2026-08-01 08:38:26 +05:30
Mihir Kandoi
09cfd1fe91 test: quantity handling in the supplier selection dialog
Asserts the requested quantity reaches the Purchase Order item and that rows
without a supplier, or with a quantity that is zero, negative or beyond the
pending quantity, are rejected.
2026-08-01 08:38:15 +05:30
Mihir Kandoi
da83370c5c feat: adjust the ordered quantity in the supplier selection dialog
The dialog prefilled the pending quantity of each Material Request item but
kept it read only, so ordering less than what was requested meant editing the
Purchase Order afterwards.

The quantity is now editable and is validated against the pending quantity of
its Material Request item, both in the dialog and on the server. The requested
quantity is handed to the mapper as the pending quantity of the source row, so
the existing mapping - including the subcontracting conversions - derives the
Purchase Order quantities from it unchanged.
2026-08-01 08:38:10 +05:30
Mihir Kandoi
65be201ed6 test: supplier selection when creating Purchase Orders from Material Request
Covers the default supplier lookup for pending items, the supplier passed
through to a single mapped order, the grouping of items into one order per
supplier, and the failure when an item is sent without a supplier.
2026-07-31 22:22:55 +05:30
Mihir Kandoi
e8df7b4a90 feat: select a supplier per item when creating Purchase Orders from Material Request
Creating a Purchase Order from a Material Request mapped every pending item
into a single order, leaving the buyer to split it by hand whenever the items
came from different vendors.

The Create action now reads the default supplier of each pending item (item,
item group, then brand defaults). When the items resolve to more than one
distinct supplier - including the case where only some of them have a default -
a dialog lists the items with their default supplier prefilled and editable.
Submitting it groups the items by the chosen supplier and creates one draft
Purchase Order per group.

When every item resolves to the same supplier the order is mapped straight
away with that supplier set, and when none of them has a default supplier the
previous behaviour is unchanged.
2026-07-31 22:22:49 +05:30
Mihir Kandoi
f4d70c2d60 test: fractional conversion factor survives Material Request to Purchase Order
Fails before the fix with 0.45 != 0.453592292 on a site with Float
Precision 2, and 0.454 on the default of 3.
2026-07-31 21:54:05 +05:30
Mihir Kandoi
269cc6ee3b fix: preserve UOM conversion factor precision in transactions
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.
2026-07-31 21:53:59 +05:30
R-Jayaraman
cde2963da1 test(purchase): add coverage for zero-qty return rejection 2026-07-31 13:31:10 +05:30
R-Jayaraman
b63066ed44 fix(purchase): reject purchase returns where every item has zero quantity
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.
2026-07-31 13:31:01 +05:30
nishkagosalia
136f92db04 fix: handling negative grand total 2026-07-29 14:31:33 +05:30
Mohd Haris
06bfc23436 fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments
`get_linked_dunnings_as_per_state` joins Dunning to its Overdue Payment child
table without DISTINCT. When a Sales Invoice has more than one overdue
installment, its Dunning holds one Overdue Payment row per installment, so the
query returns the same Dunning name once per row.

`update_linked_dunnings` then loads that Dunning name into a separate document
object for each duplicate row and saves each one. The first save bumps the
`modified` timestamp, so the second (now stale) save fails with
`TimestampMismatchError` ("Document has been modified after you have opened
it"). The error is raised on the Dunning while the user is submitting a Payment
Entry, making it confusing, and payments for such invoices cannot be posted at
all.

Add DISTINCT so each linked Dunning is returned (and saved) exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:04:31 +05:30
259 changed files with 148245 additions and 54141 deletions

View File

@@ -170,6 +170,13 @@ audit of these fixes found four recurring mistakes:
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
- **Collation-dependent pick (text columns)** — `Max()`/`Min()` over text is a *sort*, and the two
engines sort text differently: MariaDB's `utf8mb4` collations fold case, PostgreSQL (as CI runs
it) orders by byte value. `MAX('abc', 'ABD')` is `ABD` on MariaDB and `abc` on PostgreSQL. So a
`Max()` over a text column that varies **in case** within its group is a live P2 divergence, not
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
MariaDB on case. Fix: take a representative row rather than sorting text.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.

View File

@@ -171,7 +171,30 @@ jobs:
update_to_version 16 3.14
echo "Updating to latest version"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
fallback_to_develop=0
if [ -n "${GITHUB_BASE_REF:-}" ]; then
frappe_ref="refs/heads/$GITHUB_BASE_REF"
fallback_to_develop=1
elif [ "${GITHUB_REF_TYPE:-}" = "branch" ]; then
frappe_ref="$GITHUB_REF"
fallback_to_develop=1
elif [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
frappe_ref="$GITHUB_REF"
else
echo "Unsupported GitHub ref type: '${GITHUB_REF_TYPE:-unset}'"
exit 1
fi
ls_remote_status=0
git -C "apps/frappe" ls-remote --exit-code upstream "$frappe_ref" >/dev/null \
|| ls_remote_status=$?
if [ "$ls_remote_status" -eq 2 ] && [ "$fallback_to_develop" -eq 1 ]; then
echo "frappe has no '$frappe_ref'; falling back to develop"
frappe_ref=refs/heads/develop
elif [ "$ls_remote_status" -ne 0 ]; then
exit "$ls_remote_status"
fi
git -C "apps/frappe" fetch --depth 1 upstream "$frappe_ref"
git -C "apps/frappe" checkout -q -f FETCH_HEAD
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"

View File

@@ -23,3 +23,5 @@ jobs:
steps:
- uses: alyf-de/po-review-action@v1.1.0
with:
hidden-po-files: eo.po

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ erpnext/accounts/ @ruthra-kumar
erpnext/assets/ @khushi8112
erpnext/regional @ruthra-kumar
erpnext/selling @ruthra-kumar
banking/ @nikkothari22
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi

View File

@@ -24,10 +24,10 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.17.1",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.2",
"jotai-family": "^1.1.0",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
@@ -39,7 +39,7 @@
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^8.3.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
@@ -48,14 +48,14 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.16"
"vite": "^8.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.5",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",

View File

@@ -19,13 +19,22 @@ import {
import { cn } from "@/lib/utils"
import _ from "@/lib/translate"
import { selectedBankAccountAtom } from "./bankRecAtoms"
import { useFrappeGetDocList } from "frappe-react-sdk"
import ErrorBanner from "@/components/ui/error-banner"
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
const { data: companies, error } = useFrappeGetDocList("Company", {
limit: 0,
fields: ["name"],
}, 'company_list', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const options = companies?.map((company: { name: string }) => company.name) || []
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
@@ -42,6 +51,10 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
}
}
if (error) {
return <ErrorBanner error={error} />
}
return (<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button

View File

@@ -1,7 +1,9 @@
import { useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
getOnInit: true,
})
export const useCurrentCompany = () => {
const selectedCompany = useAtomValue(selectedCompanyAtom)

View File

@@ -177,7 +177,7 @@
dependencies:
tslib "^2.0.0"
"@emnapi/core@1.11.1", "@emnapi/core@^1.11.1":
"@emnapi/core@^1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6"
integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==
@@ -185,7 +185,7 @@
"@emnapi/wasi-threads" "1.2.2"
tslib "^2.4.0"
"@emnapi/runtime@1.11.1", "@emnapi/runtime@^1.11.1":
"@emnapi/runtime@^1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24"
integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==
@@ -234,10 +234,10 @@
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/eslintrc@^3.3.5":
version "3.3.5"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60"
integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==
"@eslint/eslintrc@^3.3.6":
version "3.3.6"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.6.tgz#d22bfd6b3a7d8e1f2c0b2f2e6de111b53ec6e13e"
integrity sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==
dependencies:
ajv "^6.14.0"
debug "^4.3.2"
@@ -245,14 +245,14 @@
globals "^14.0.0"
ignore "^5.2.0"
import-fresh "^3.2.1"
js-yaml "^4.1.1"
js-yaml "^4.3.0"
minimatch "^3.1.5"
strip-json-comments "^3.1.1"
"@eslint/js@9.39.4", "@eslint/js@^9.39.4":
version "9.39.4"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1"
integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==
"@eslint/js@9.39.5":
version "9.39.5"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.5.tgz#6f2fbcff75500d229d535e0a949ae13472c84787"
integrity sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==
"@eslint/object-schema@^2.1.7":
version "2.1.7"
@@ -359,17 +359,17 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6":
"@napi-rs/wasm-runtime@^1.1.4":
version "1.1.6"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz#ed33806d0f9be98dc76d0c3d4fd872fda701b5d5"
integrity sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==
dependencies:
"@tybys/wasm-util" "^0.10.3"
"@oxc-project/types@=0.137.0":
version "0.137.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.137.0.tgz#56e77f8bb221fa05f18b1cd34d73f94f0954a773"
integrity sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==
"@oxc-project/types@=0.143.0":
version "0.143.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.143.0.tgz#c3e4f3178b7b54e4dd194eac6d45258a60f0092b"
integrity sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==
"@radix-ui/number@1.1.2":
version "1.1.2"
@@ -1032,84 +1032,75 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.2.tgz#0761a82af55c7e302d5b509eaf1c97ea1fc5feea"
integrity sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==
"@rolldown/binding-android-arm64@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz#cc6153029c3d9afc9caaae2dc362d899ae94ac4f"
integrity sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==
"@rolldown/binding-android-arm64@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz#001b8b0b01844701efda1bb6bed84b681c4a488b"
integrity sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==
"@rolldown/binding-darwin-arm64@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz#3af681a5d7610340257b3ac7753353b23e884765"
integrity sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==
"@rolldown/binding-darwin-arm64@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz#5e87c602ed634a6fef092e2162e24fbfb881c4ec"
integrity sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==
"@rolldown/binding-darwin-x64@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz#80ada35e9f35efb7e48a887444ce2052f615d645"
integrity sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==
"@rolldown/binding-darwin-x64@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz#f32e0b286714bd03a421d693415d05d97d265b77"
integrity sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==
"@rolldown/binding-freebsd-x64@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz#65b2bbb82f005f08aeeff0b6d81e19be68360201"
integrity sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==
"@rolldown/binding-freebsd-x64@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz#5f38ad5761b6b7b21b57a99566bb52634c60ab19"
integrity sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==
"@rolldown/binding-linux-arm-gnueabihf@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz#7e8d34ad0c7bcfd3baed268e9798571e3888ca71"
integrity sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==
"@rolldown/binding-linux-arm-gnueabihf@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz#ab4dcd07f1bd88e8d659ae0c3bb9d2f290adb897"
integrity sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==
"@rolldown/binding-linux-arm64-gnu@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz#52bbf400ff219bda1e56c042160d96deb08bfecc"
integrity sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==
"@rolldown/binding-linux-arm64-gnu@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz#d279b7016039a725fb66d82784b9841f42df83da"
integrity sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==
"@rolldown/binding-linux-arm64-musl@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz#9e82899186f73329f3d8155fa1618ae2e86ffa2a"
integrity sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==
"@rolldown/binding-linux-arm64-musl@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz#d08bbc93d2742214548c5adf7df7788944e5a89a"
integrity sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==
"@rolldown/binding-linux-ppc64-gnu@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz#050520177316586ccad816eb466ea11015e17ba7"
integrity sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==
"@rolldown/binding-linux-ppc64-gnu@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz#6418e63745b3193f26ab3bb88744b3a4a1356d7c"
integrity sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==
"@rolldown/binding-linux-s390x-gnu@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz#50efa7b20219c6e31235fded0fd3427f36123e5a"
integrity sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==
"@rolldown/binding-linux-s390x-gnu@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz#77ec30d0704cf4eb1cb4a63f501c9852c6728cf4"
integrity sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==
"@rolldown/binding-linux-x64-gnu@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz#c5973445113dff50d4077d0edaa4b8a69533dc6f"
integrity sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==
"@rolldown/binding-linux-x64-gnu@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz#3b9b6e0dd3e86c597f42858748ca25f1dfd58ed8"
integrity sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==
"@rolldown/binding-linux-x64-musl@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz#ddb023f7fc98ccbb8c1b683545216ca7b4e7ebdd"
integrity sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==
"@rolldown/binding-linux-x64-musl@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz#f78033c592c8bd2af48284a45f8e4baaa0befbf5"
integrity sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==
"@rolldown/binding-openharmony-arm64@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz#832a6da3472722427c73d178c75681858b76aeed"
integrity sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==
"@rolldown/binding-openharmony-arm64@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz#36e951f5a6fca922a5205e283d0a82b9f98199ca"
integrity sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==
"@rolldown/binding-wasm32-wasi@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz#256cdcc06ad9ada611606526f319642fc0830b0f"
integrity sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==
dependencies:
"@emnapi/core" "1.11.1"
"@emnapi/runtime" "1.11.1"
"@napi-rs/wasm-runtime" "^1.1.6"
"@rolldown/binding-win32-arm64-msvc@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz#c1e494ac47e13bd857fca0b3ad59c33580241f7e"
integrity sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==
"@rolldown/binding-win32-arm64-msvc@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz#e735c7024a5e17ebaf13689112fa0bdfc6886c38"
integrity sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==
"@rolldown/binding-win32-x64-msvc@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz#f06c09db5c8ad4b6904b4d406c9b6f17f392b5c6"
integrity sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==
"@rolldown/binding-win32-x64-msvc@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz#b0effffcd6872f8a021373eb437916b1b52283a4"
integrity sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==
"@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1":
version "1.0.1"
@@ -1879,18 +1870,18 @@ eslint-visitor-keys@^5.0.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@^9.39.1:
version "9.39.4"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5"
integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==
eslint@9.39.5:
version "9.39.5"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.5.tgz#2a4e3c8b0f753196efae943c8ffaa8730fc6a3fa"
integrity sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
"@eslint/config-array" "^0.21.2"
"@eslint/config-helpers" "^0.4.2"
"@eslint/core" "^0.17.0"
"@eslint/eslintrc" "^3.3.5"
"@eslint/js" "9.39.4"
"@eslint/eslintrc" "^3.3.6"
"@eslint/js" "9.39.5"
"@eslint/plugin-kit" "^0.4.1"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
@@ -2033,21 +2024,21 @@ form-data@^4.0.5:
hasown "^2.0.4"
mime-types "^2.1.35"
frappe-js-sdk@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/frappe-js-sdk/-/frappe-js-sdk-1.14.0.tgz#6cfc3a91598dc179890ff2b1db675f228bd1565a"
integrity sha512-v0n75UP8SffSH77QWbvD43FXndF+P8Us1MyIVH287uwPpLYYk7tXCA/ftNTn3a2YV7VdncNU7XDwxYNgAoBt8w==
frappe-js-sdk@^1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/frappe-js-sdk/-/frappe-js-sdk-1.14.1.tgz#b6737e64e6019ee072a667be6f56ebad3b3dd81a"
integrity sha512-HxY1KygYs/05ykjKo1YB0hQlUOiA3odEg+TWKIWRgNH0bURlXxSPiNKtBmQVOm92EhDGyqQQjQe5I1QdKSImbw==
dependencies:
axios "^1.18.1"
frappe-react-sdk@^1.17.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/frappe-react-sdk/-/frappe-react-sdk-1.17.0.tgz#4777261b80e6ba195a007e61e75757c02b881cc1"
integrity sha512-1Q0T5Qdtm1+g0sb0PSxIvleVK17gG2QTXgRVeCC927JCFgH8UsrQNbx8MZ1ojlPIp8AahA0NRwH+C4RL0cT8sw==
frappe-react-sdk@^1.17.1:
version "1.17.1"
resolved "https://registry.yarnpkg.com/frappe-react-sdk/-/frappe-react-sdk-1.17.1.tgz#7bd0bc87065e41acae5474d27096a18a59d56780"
integrity sha512-9FxtG8kb1kUHx67P0AsdFeEnI39HyPP2SXDquy7IkYWRX4XCA/gdrP75u/idH+SiDoy3jWJLbewSdqHq9oq5gw==
dependencies:
frappe-js-sdk "^1.14.0"
frappe-js-sdk "^1.14.1"
socket.io-client "4.7.1"
swr "^2.4.1"
swr "^2.4.2"
fsevents@~2.3.3:
version "2.3.3"
@@ -2349,25 +2340,25 @@ jiti@^2.7.0:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
jotai-family@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/jotai-family/-/jotai-family-1.0.2.tgz#11d9f88a39579aaf0d80dcd8826b8c32cf6b5269"
integrity sha512-U1aTMGxmsmz2Z8gaJD1/ljmMnsmG4/dqrcsfwGbjWV7p6boB9Vy3+75YwUpwPj7GbLNJk9O8rl1VNi8d7+6Rxw==
jotai-family@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/jotai-family/-/jotai-family-1.1.0.tgz#87eab9c6e8b241f3bc39b6f04a3b55247ebd40bb"
integrity sha512-T1ACRsLnN5Mu0B8/DIWUW1hW7x1RmpBprm/+CWmUTf4ZZUmIFEQpBJ9PNQtXPjdUD2uu8RvwBxr7nJPdCw5BXg==
jotai@^2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.20.1.tgz#473274f1b78c60acce1b868be5655b3c706dec8e"
integrity sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==
jotai@^2.20.2:
version "2.20.2"
resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.20.2.tgz#fccdc02ee68b314c2a723a8b9c35de6fcca5e644"
integrity sha512-aHB4CNb9qRcyf0mwSB6EO5bCGAjx8cTwFgOFCE2leOnTzqACbnSWG8XoWB3LxCT1Qoj03I1OWAHszDmN4uHb/w==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.1:
version "4.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
js-yaml@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
dependencies:
argparse "^2.0.1"
@@ -2416,57 +2407,112 @@ lightningcss-android-arm64@1.32.0:
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
lightningcss-android-arm64@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907"
integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==
lightningcss-darwin-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
lightningcss-darwin-arm64@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6"
integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==
lightningcss-darwin-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
lightningcss-darwin-x64@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361"
integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==
lightningcss-freebsd-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
lightningcss-freebsd-x64@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b"
integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==
lightningcss-linux-arm-gnueabihf@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
lightningcss-linux-arm-gnueabihf@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de"
integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==
lightningcss-linux-arm64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
lightningcss-linux-arm64-gnu@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa"
integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==
lightningcss-linux-arm64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
lightningcss-linux-arm64-musl@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6"
integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==
lightningcss-linux-x64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
lightningcss-linux-x64-gnu@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55"
integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==
lightningcss-linux-x64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
lightningcss-linux-x64-musl@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf"
integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==
lightningcss-win32-arm64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
lightningcss-win32-arm64-msvc@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d"
integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==
lightningcss-win32-x64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
lightningcss@1.32.0, lightningcss@^1.32.0:
lightningcss-win32-x64-msvc@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f"
integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==
lightningcss@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
@@ -2485,6 +2531,25 @@ lightningcss@1.32.0, lightningcss@^1.32.0:
lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0"
lightningcss@^1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b"
integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
lightningcss-android-arm64 "1.33.0"
lightningcss-darwin-arm64 "1.33.0"
lightningcss-darwin-x64 "1.33.0"
lightningcss-freebsd-x64 "1.33.0"
lightningcss-linux-arm-gnueabihf "1.33.0"
lightningcss-linux-arm64-gnu "1.33.0"
lightningcss-linux-arm64-musl "1.33.0"
lightningcss-linux-x64-gnu "1.33.0"
lightningcss-linux-x64-musl "1.33.0"
lightningcss-win32-arm64-msvc "1.33.0"
lightningcss-win32-x64-msvc "1.33.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -3032,10 +3097,10 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nanoid@^3.3.12:
version "3.3.15"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316"
integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==
nanoid@^3.3.17:
version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==
natural-compare@^1.4.0:
version "1.4.0"
@@ -3125,12 +3190,17 @@ picomatch@^4.0.4:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
postcss@^8.5.16:
version "8.5.16"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.16.tgz#1230ce0b5df354c24c0ea45f99ce5f6a88279d28"
integrity sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==
picomatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
postcss@^8.5.25:
version "8.5.26"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620"
integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==
dependencies:
nanoid "^3.3.12"
nanoid "^3.3.17"
picocolors "^1.1.1"
source-map-js "^1.2.1"
@@ -3306,10 +3376,10 @@ react-remove-scroll@^2.7.2:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-router@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-8.1.0.tgz#242c9169bdf8da4e080279c92048f0016eb3aa4b"
integrity sha512-Mdfi61uObuvWNN9OhChOC0HV6YWOIfKRzEWOvCHRSuQg8IM+Nv10edaM/2HE8ZixBpUTdQbruyWqC3sDkkh9vw==
react-router@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-8.3.0.tgz#b7bc69c3e3833ba79ebf892aef03d62b649508f8"
integrity sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==
dependencies:
cookie-es "^3.1.1"
@@ -3382,29 +3452,28 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
rolldown@~1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.1.3.tgz#87072bfd0d1bdd02a66076a261a62e8e49b3f0e2"
integrity sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==
rolldown@~1.2.1:
version "1.2.3"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.3.tgz#103bdcbbd575d51265277b8b510f080827b6eb6f"
integrity sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==
dependencies:
"@oxc-project/types" "=0.137.0"
"@oxc-project/types" "=0.143.0"
"@rolldown/pluginutils" "^1.0.0"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.1.3"
"@rolldown/binding-darwin-arm64" "1.1.3"
"@rolldown/binding-darwin-x64" "1.1.3"
"@rolldown/binding-freebsd-x64" "1.1.3"
"@rolldown/binding-linux-arm-gnueabihf" "1.1.3"
"@rolldown/binding-linux-arm64-gnu" "1.1.3"
"@rolldown/binding-linux-arm64-musl" "1.1.3"
"@rolldown/binding-linux-ppc64-gnu" "1.1.3"
"@rolldown/binding-linux-s390x-gnu" "1.1.3"
"@rolldown/binding-linux-x64-gnu" "1.1.3"
"@rolldown/binding-linux-x64-musl" "1.1.3"
"@rolldown/binding-openharmony-arm64" "1.1.3"
"@rolldown/binding-wasm32-wasi" "1.1.3"
"@rolldown/binding-win32-arm64-msvc" "1.1.3"
"@rolldown/binding-win32-x64-msvc" "1.1.3"
"@rolldown/binding-android-arm64" "1.2.3"
"@rolldown/binding-darwin-arm64" "1.2.3"
"@rolldown/binding-darwin-x64" "1.2.3"
"@rolldown/binding-freebsd-x64" "1.2.3"
"@rolldown/binding-linux-arm-gnueabihf" "1.2.3"
"@rolldown/binding-linux-arm64-gnu" "1.2.3"
"@rolldown/binding-linux-arm64-musl" "1.2.3"
"@rolldown/binding-linux-ppc64-gnu" "1.2.3"
"@rolldown/binding-linux-s390x-gnu" "1.2.3"
"@rolldown/binding-linux-x64-gnu" "1.2.3"
"@rolldown/binding-linux-x64-musl" "1.2.3"
"@rolldown/binding-openharmony-arm64" "1.2.3"
"@rolldown/binding-win32-arm64-msvc" "1.2.3"
"@rolldown/binding-win32-x64-msvc" "1.2.3"
safe-expr-eval@^1.0.4:
version "1.0.4"
@@ -3505,10 +3574,10 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
swr@^2.4.1:
version "2.4.2"
resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.2.tgz#741ba9c804db756cfa966376cbc33f84a2d88cfd"
integrity sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==
swr@^2.4.2:
version "2.5.0"
resolved "https://registry.yarnpkg.com/swr/-/swr-2.5.0.tgz#186c68b08b3419a5ebba4cea53d776467c9fa2d4"
integrity sha512-W0GomadRJe9OfzIoRX0kZHhDSaRRfdiOUeH6uazgR05SibBhDNwfdTbhAxmFtObvkf59fFymo22mooKukosvNA==
dependencies:
dequal "^2.0.3"
use-sync-external-store "^1.6.0"
@@ -3705,15 +3774,15 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@^8.0.16:
version "8.1.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.1.2.tgz#3ac29b5868ccf28c59321391be1ebe906f135ebd"
integrity sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==
vite@^8.2.1:
version "8.2.1"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.1.tgz#6fc8d8bb843bd52353091fac978e194d4de5b31d"
integrity sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==
dependencies:
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.16"
rolldown "~1.1.3"
lightningcss "^1.33.0"
picomatch "^4.0.5"
postcss "^8.5.25"
rolldown "~1.2.1"
tinyglobby "^0.2.17"
optionalDependencies:
fsevents "~2.3.3"

View File

@@ -6,88 +6,148 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_outstanding_reference_documents,
get_payment_entry,
)
from erpnext.utilities.bulk_transaction import transaction_processing
@frappe.whitelist(methods=["POST"])
def create_payment_entries(
grouped_invoices: str | list | None = None,
ungrouped_invoices: str | list | None = None,
):
def create_payment_entries(invoices: str | list | None = None):
"""Create draft Payment Entries from AP report invoice selection."""
frappe.has_permission("Payment Entry", "create", throw=True)
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
if not grouped_invoices and not ungrouped_invoices:
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
if not names:
frappe.throw(_("No Purchase Invoices selected"))
if ungrouped_invoices:
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
transaction_processing(data, "Purchase Invoice", "Payment Entry")
payable, excluded = _partition_payable_invoices(names)
if not payable:
frappe.throw(_("None of the selected invoices are payable"))
if grouped_invoices:
groups = {}
for d in grouped_invoices:
key = (d["supplier"], d["party_account"])
groups.setdefault(
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
)["vouchers"].append(d["voucher_no"])
# invoices sharing a (supplier, payable account) are combined into one Payment Entry
groups = {}
for d in payable:
key = (d["supplier"], d["party_account"])
groups.setdefault(
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
)["vouchers"].append(d["voucher_no"])
frappe.msgprint(
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
)
frappe.enqueue(
make_grouped_payment_entries,
queue="long",
timeout=1500,
groups=list(groups.values()),
)
def make_grouped_payment_entries(groups):
created, failed = 0, 0
for group in groups:
supplier = group["supplier"]
try:
frappe.db.savepoint("bulk_pe")
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
if not pe:
frappe.db.rollback(save_point="bulk_pe")
failed += 1
frappe.log_error(
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
message=_(
"No outstanding invoices found for the selected vouchers in account {0}"
).format(group["party_account"]),
)
continue
pe.flags.ignore_validate = True
pe.set_title_field()
pe.insert(ignore_mandatory=True)
for group in groups.values():
if _create_payment_entry(group):
created += 1
except Exception:
frappe.db.rollback(save_point="bulk_pe")
else:
failed += 1
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
message = _("Created {0} draft Grouped Payment Entries").format(created)
message = _("Created {0} draft Payment Entries").format(created)
if excluded:
message += "" + _("{0} excluded (not payable)").format(len(excluded))
if failed:
message += "" + _("{0} skipped (see Error Log)").format(failed)
message += "" + _("{0} failed (see Error Log)").format(failed)
frappe.msgprint(message, title=_("Bulk Payment Entries"), indicator="green")
frappe.publish_realtime(
"msgprint",
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
user=frappe.session.user,
after_commit=True,
@frappe.whitelist()
def get_payable_invoices(invoices: str | list | None = None):
"""Return the live payable subset of the selected invoices for the report dialog."""
frappe.has_permission("Payment Entry", "create", throw=True)
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
payable, excluded = _partition_payable_invoices(names)
currency = None
if payable:
company = frappe.get_cached_value("Purchase Invoice", payable[0]["voucher_no"], "company")
currency = frappe.get_cached_value("Company", company, "default_currency")
return {"payable": payable, "excluded": excluded, "currency": currency}
def _partition_payable_invoices(names):
"""Split submitted Purchase Invoices into payable ones and excluded ones (with reason).
Returns are debit notes, internal transfers are inter-company, and non-positive
outstanding means already settled — none are valid targets for a supplier payment.
"""
if not names:
return [], []
rows = frappe.get_list(
"Purchase Invoice",
filters={"name": ["in", names], "docstatus": 1},
fields=[
"name",
"supplier",
"credit_to",
"outstanding_amount",
"conversion_rate",
"is_return",
"is_internal_supplier",
],
limit_page_length=0,
)
payable, excluded = [], []
for r in rows:
if r.is_return:
excluded.append({"voucher_no": r.name, "reason": _("Debit Note")})
elif r.is_internal_supplier:
excluded.append({"voucher_no": r.name, "reason": _("Internal Transfer")})
elif flt(r.outstanding_amount) <= 0:
excluded.append({"voucher_no": r.name, "reason": _("Already Paid")})
else:
payable.append(
{
"voucher_no": r.name,
"supplier": r.supplier,
"party_account": r.credit_to,
"outstanding": flt(r.outstanding_amount) * flt(r.conversion_rate or 1),
}
)
# names not returned were cancelled/deleted or no longer readable after the report loaded
found = {r.name for r in rows}
for name in names:
if name not in found:
excluded.append({"voucher_no": name, "reason": _("Not available")})
return payable, excluded
def _create_payment_entry(group):
supplier = group["supplier"]
try:
frappe.db.savepoint("bulk_pe")
if len(group["vouchers"]) == 1:
pe = _build_single_payment_entry(group["vouchers"][0])
else:
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
if not pe:
frappe.db.rollback(save_point="bulk_pe")
frappe.log_error(
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
message=_("No outstanding amount for the selected invoice(s)."),
)
return False
pe.flags.ignore_validate = True
pe.set_title_field()
pe.insert(ignore_mandatory=True)
return True
except Exception:
frappe.db.rollback(save_point="bulk_pe")
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
return False
def _build_single_payment_entry(name):
pe = get_payment_entry("Purchase Invoice", name)
# guard against a stale report row: nothing to allocate means the invoice is already settled
if not pe.references or not any(flt(r.allocated_amount) for r in pe.references):
return None
return pe
def _build_grouped_payment_entry(supplier, party_account, names):
name_set = set(names)
pe = get_payment_entry("Purchase Invoice", names[0])
pe.set("references", [])
@@ -101,8 +161,9 @@ def _build_grouped_payment_entry(supplier, party_account, names):
}
)
# get_negative_outstanding_invoices ignores the vouchers filter, so bound refs to the selection
for r in refs:
if r.voucher_type != "Purchase Invoice":
if r.voucher_type != "Purchase Invoice" or r.voucher_no not in name_set:
continue
pe.append(
"references",

View File

@@ -71,4 +71,6 @@ def get_shipping_address(company: str, address: str | None = None):
if address:
address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
return address_as_dict.get("name"), frappe.render_template(
address_template, address_as_dict, restrict_globals=True
)

View File

@@ -730,6 +730,8 @@ def get_company_default_account_fields():
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
"exchange_gain_account": "Exchange Gain Account",
"exchange_loss_account": "Exchange Loss Account",
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
"round_off_account": "Round Off Account",
"default_deferred_revenue_account": "Default Deferred Revenue Account",

View File

@@ -179,6 +179,9 @@
},
"Impairment": {
"account_category": "Operating Expenses"
},
"Exchange Loss": {
"account_category": "Operating Expenses"
}
},
"root_type": "Expense"
@@ -196,6 +199,10 @@
"account_type": "Income Account"
},
"Indirect Income": {
"Exchange Gain": {
"account_type": "Income Account",
"account_category": "Other Operating Income"
},
"account_type": "Income Account",
"is_group": 1
},

View File

@@ -138,6 +138,7 @@ def get():
_("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"},
_("Impairment"): {"account_category": "Operating Expenses"},
_("Tax Expense"): {"account_category": "Tax Expense"},
_("Exchange Loss"): {"account_category": "Operating Expenses"},
},
"root_type": "Expense",
},
@@ -149,6 +150,7 @@ def get():
_("Indirect Income"): {
_("Interest Income"): {"account_category": "Investment Income"},
_("Interest on Fixed Deposits"): {"account_category": "Investment Income"},
_("Exchange Gain"): {"account_category": "Other Operating Income"},
"is_group": 1,
},
"root_type": "Income",

View File

@@ -233,6 +233,7 @@ def get():
},
_("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"},
_("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"},
_("Exchange Loss"): {"account_number": "5226", "account_category": "Operating Expenses"},
"account_number": "5200",
},
"root_type": "Expense",
@@ -250,6 +251,10 @@ def get():
"account_number": "4220",
"account_category": "Investment Income",
},
_("Exchange Gain"): {
"account_number": "4230",
"account_category": "Other Operating Income",
},
"is_group": 1,
"account_number": "4200",
},

View File

@@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
.join(overdue_payment)
.on(overdue_payment.parent == dunning.name)
.select(dunning.name)
.distinct()
.where(
(dunning.status == state)
& (dunning.docstatus != 2)

View File

@@ -123,6 +123,41 @@ class TestDunning(ERPNextTestSuite):
self.assertEqual(sales_invoice.status, "Overdue")
self.assertEqual(dunning.status, "Unresolved")
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
"""
When an invoice has more than one overdue installment, its Dunning holds one
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
must resolve the Dunning without raising a TimestampMismatchError caused by the
same Dunning being loaded and saved more than once.
"""
create_payment_terms_template_for_dunning()
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -15),
qty=1,
rate=100,
do_not_submit=True,
)
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
sales_invoice.submit()
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
# Two overdue installments -> two overdue payment rows for the same invoice.
self.assertEqual(len(dunning.overdue_payments), 2)
dunning.submit()
self.assertEqual(dunning.status, "Unresolved")
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
pe.reference_no, pe.reference_date = "3", nowdate()
pe.insert()
pe.submit()
sales_invoice.reload()
dunning.reload()
self.assertEqual(sales_invoice.outstanding_amount, 0)
self.assertEqual(dunning.status, "Resolved")
def test_dunning_resolution_from_credit_note(self):
"""
Test that dunning is resolved when a credit note is issued against the original invoice.

View File

@@ -235,7 +235,7 @@ Object.assign(erpnext.journal_entry, {
lock_reversal_entry(frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -184,6 +184,7 @@ class JournalEntryReferenceValidator:
continue
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
self._validate_block_invoice(invoice)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
@@ -197,3 +198,15 @@ class JournalEntryReferenceValidator:
reference_type, reference_name, invoice.outstanding_amount
)
)
def _validate_block_invoice(self, invoice):
"""Payment cannnot be booked against blocked Purchase Invoices"""
if invoice.doctype != "Purchase Invoice":
return
if invoice.invoice_is_blocked():
frappe.throw(
_("{0} {1} is blocked and on hold until {2}.").format(
invoice.doctype, invoice.name, invoice.release_date
)
)

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
@@ -748,6 +748,69 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def make_jv_against_purchase_invoice(self, invoice, amount=100):
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
jv.accounts[0].party_type = "Supplier"
jv.accounts[0].party = invoice.supplier
jv.accounts[0].reference_type = "Purchase Invoice"
jv.accounts[0].reference_name = invoice.name
return jv
def test_jv_against_purchase_invoice_respects_hold_state(self):
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
release_date = add_days(nowdate(), 10)
def never_held():
return make_purchase_invoice()
def held_until_a_future_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
return invoice
def held_without_a_release_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Under dispute")
return invoice
def held_until_a_date_that_has_passed():
invoice = held_until_a_future_date()
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
return invoice
def unblocked_again():
invoice = held_until_a_future_date()
invoice.unblock_invoice()
return invoice
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
with self.subTest(build_invoice.__name__):
jv = self.make_jv_against_purchase_invoice(build_invoice())
self.assertRaisesRegex(frappe.ValidationError, "is blocked and on hold until", jv.insert)
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
with self.subTest(build_invoice.__name__):
invoice = build_invoice()
jv = self.make_jv_against_purchase_invoice(invoice)
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")

View File

@@ -950,6 +950,61 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_exchange_gain_loss_split_accounts(self):
gain_account = create_account(
account_name="_Test Exchange Gain",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
loss_account = create_account(
account_name="_Test Exchange Loss",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account)
frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "")
si_gain = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
)
pe_gain = get_payment_entry("Sales Invoice", si_gain.name, bank_account="_Test Bank USD - _TC")
pe_gain.reference_no = "1"
pe_gain.reference_date = "2016-01-01"
pe_gain.source_exchange_rate = 55
pe_gain.save()
self.assertEqual(pe_gain.references[0].exchange_gain_loss, 500)
pe_gain.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_gain.name), gain_account)
si_loss = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=55,
)
pe_loss = get_payment_entry("Sales Invoice", si_loss.name, bank_account="_Test Bank USD - _TC")
pe_loss.reference_no = "2"
pe_loss.reference_date = "2016-01-01"
pe_loss.source_exchange_rate = 50
pe_loss.save()
self.assertEqual(pe_loss.references[0].exchange_gain_loss, -500)
pe_loss.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_loss.name), loss_account)
def get_gain_loss_journal_account(self, payment_entry_name: str) -> str | None:
return frappe.db.get_value(
"Journal Entry Account",
{"reference_type": "Payment Entry", "reference_name": payment_entry_name, "docstatus": 1},
"account",
)
def test_payment_entry_against_sales_invoice_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center

View File

@@ -18,6 +18,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
is_any_doc_running,
)
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
@@ -485,9 +486,6 @@ class PaymentReconciliation(Document):
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
)
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
default_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
entries = []
for pay in args.get("payments"):
@@ -507,7 +505,10 @@ class PaymentReconciliation(Document):
pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
res.difference_account = default_exchange_gain_loss_account
is_gain = (
res.difference_amount > 0 if self.party_type == "Customer" else res.difference_amount < 0
)
res.difference_account = get_exchange_gain_loss_account(self.company, is_gain)
res.exchange_rate = inv.get("exchange_rate")
res.update({"gain_loss_posting_date": pay.get("posting_date")})
if not pay.get("is_advance"):

View File

@@ -6,6 +6,7 @@ import frappe
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -187,6 +188,150 @@ class TestPaymentReconciliation(ERPNextTestSuite):
)
return je
def setup_split_exchange_accounts(self):
gain_account = create_account(
account_name="_Test PR Split Exchange Gain",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
loss_account = create_account(
account_name="_Test PR Split Exchange Loss",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account)
frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "")
return gain_account, loss_account
def create_foreign_currency_sales_invoice(self, conversion_rate):
si = self.create_sales_invoice(
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
si.customer = self.customer_usd
si.currency = "USD"
si.conversion_rate = conversion_rate
si.debit_to = self.debtors_usd
si.save().submit()
return si
def create_foreign_currency_journal_payment(self, debtors_account, exchange_rate):
je = self.create_journal_entry(self.bank, debtors_account, 100, nowdate())
je.multi_currency = 1
je.accounts[0].exchange_rate = 1
je.accounts[0].credit_in_account_currency = 0
je.accounts[0].credit = 0
je.accounts[0].debit_in_account_currency = 100 * exchange_rate
je.accounts[0].debit = 100 * exchange_rate
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer_usd
je.accounts[1].exchange_rate = exchange_rate
je.accounts[1].credit_in_account_currency = 100
je.accounts[1].credit = 100 * exchange_rate
je.accounts[1].debit_in_account_currency = 0
je.accounts[1].debit = 0
je.save()
je.submit()
return je
def test_voucher_outstanding_metadata_comes_from_one_ledger_entry(self):
"""cost_center and remarks must describe the same Payment Ledger Entry.
A voucher can post several ledger entries for one party with different cost centers and
remarks. Aggregating each column on its own can pair one entry's cost center with another's
remarks -- a row that was never posted -- and because Max() over text is a sort, MariaDB and
PostgreSQL can pick differently on top of that.
"""
from erpnext.accounts.utils import QueryPaymentLedger
je = frappe.new_doc("Journal Entry")
je.posting_date = nowdate()
je.company = self.company
je.user_remark = "aaa base remark"
for cost_center, remark, amount in (
(self.main_cc, "aaa main line", 100),
(self.sub_cc, "zzz sub line", 50),
):
je.append(
"accounts",
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"cost_center": cost_center,
"user_remark": remark,
"debit_in_account_currency": amount,
},
)
je.append(
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 150}
)
je.save()
je.submit()
posted = {
(row.cost_center, row.remarks)
for row in frappe.get_all(
"Payment Ledger Entry",
filters={"voucher_no": je.name, "delinked": 0},
fields=["cost_center", "remarks"],
)
}
self.assertGreater(len(posted), 1, "fixture must post more than one ledger entry to be meaningful")
ledger = QueryPaymentLedger()
rows = ledger.get_voucher_outstandings(
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
)
self.assertTrue(rows)
for row in rows:
self.assertIn((row.cost_center, row.remarks), posted)
def test_voucher_outstanding_splits_by_party_account(self):
"""A voucher posting to two party accounts must report each account separately.
account is the join key between the amount and outstanding CTEs. Selecting Max(account)
while grouping without it made that key an aggregate over two different row sets, so the two
sides could pick different accounts, the join would miss and the outstanding come back NULL.
It also summed amounts across accounts that need not share a currency.
"""
from erpnext.accounts.utils import QueryPaymentLedger
second_receivable = "_Test Receivable - _TC"
je = frappe.new_doc("Journal Entry")
je.posting_date = nowdate()
je.company = self.company
je.user_remark = "two receivable accounts"
for account, amount in ((self.debit_to, 100), (second_receivable, 60)):
je.append(
"accounts",
{
"account": account,
"party_type": "Customer",
"party": self.customer,
"cost_center": self.main_cc,
"debit_in_account_currency": amount,
},
)
je.append(
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 160}
)
je.save()
je.submit()
rows = QueryPaymentLedger().get_voucher_outstandings(
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
)
by_account = {row.account: row for row in rows}
self.assertEqual(set(by_account), {self.debit_to, second_receivable})
self.assertEqual(flt(by_account[self.debit_to].invoice_amount), 100)
self.assertEqual(flt(by_account[second_receivable].invoice_amount), 60)
for row in rows:
self.assertIsNotNone(row.outstanding)
def test_filter_min_max(self):
# check filter condition minimum and maximum amount
self.create_sales_invoice(qty=1, rate=300)
@@ -859,6 +1004,85 @@ class TestPaymentReconciliation(ERPNextTestSuite):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_exchange_gain_loss_split_default_account(self):
gain_account, loss_account = self.setup_split_exchange_accounts()
self.create_foreign_currency_sales_invoice(conversion_rate=80)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=85)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, 500)
self.assertEqual(pr.allocation[0].difference_account, gain_account)
pr.reconcile()
self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
def test_payment_reconciliation_difference_account_override(self):
_, loss_account = self.setup_split_exchange_accounts()
override_account = create_account(
account_name="_Test PR Override Exchange Account",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
si = self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Default, computed from the split company fields, is pre-filled onto the row...
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
# ...but the user can override it in the "Select Difference Account" dialog before reconciling,
# and that explicit choice must be what actually gets booked, not the computed default.
pr.allocation[0].difference_account = override_account
pr.reconcile()
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_usd, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
gain_loss_line_account = frappe.db.get_value(
"Journal Entry Account",
{"parent": jea_parent.parent, "account": ["!=", self.debtors_usd]},
"account",
)
self.assertEqual(gain_loss_line_account, override_account)
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
@@ -2402,6 +2626,86 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 100
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
# Pay USD 100 at an exchange rate of 90.
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = 90
pe.paid_amount = 90 * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.department = department
pe = pe.save().submit()
# Receive USD 100 from the supplier at an exchange rate of 100.
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = 100
reverse_pe.paid_amount = amount
reverse_pe.received_amount = 100 * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe.department = department
reverse_pe = reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
for row in pr.allocation:
row.department = department
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
pr.reconcile()
gain_loss_journal = frappe.db.get_value(
"Journal Entry Account",
{
"reference_type": reverse_pe.doctype,
"reference_name": reverse_pe.name,
"party": self.supplier,
"docstatus": 1,
},
"parent",
)
party_row = frappe.db.get_value(
"Journal Entry Account",
{"parent": gain_loss_journal, "party": self.supplier},
["debit", "credit"],
as_dict=True,
)
self.assertEqual(flt(party_row.debit), 1000)
self.assertEqual(flt(party_row.credit), 0)
party_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
"account": self.creditors_usd,
"party": self.supplier,
"is_cancelled": 0,
},
fields=["debit", "credit"],
)
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer_usd

View File

@@ -640,7 +640,7 @@ class PaymentRequest(Document):
}
if self.message:
return frappe.render_template(self.message, context)
return frappe.render_template(self.message, context, restrict_globals=True)
def set_failed(self):
pass

View File

@@ -6,8 +6,10 @@ import copy
import frappe
from frappe import _
from frappe.utils import add_days, flt, formatdate, getdate
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
@@ -17,6 +19,8 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
from erpnext.stock.utils import get_stock_value_on
class PeriodClosingVoucher(AccountsController):
@@ -141,6 +145,121 @@ class PeriodClosingVoucher(AccountsController):
if account_currency != company_currency:
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def before_submit(self):
if not self.has_stock_transactions():
return
self.validate_stock_accounts_balance()
self.validate_stock_closing_entry()
def has_stock_transactions(self):
if not is_perpetual_inventory_enabled(self.company):
return False
return bool(
frappe.db.exists(
"Stock Ledger Entry",
{
"company": self.company,
"is_cancelled": 0,
"posting_date": ("<=", self.period_end_date),
},
)
)
def validate_stock_accounts_balance(self):
precision = frappe.get_precision("GL Entry", "debit")
account_balance = flt(self.get_stock_accounts_balance(), precision)
stock_value = flt(
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
)
if account_balance == stock_value:
return
currency = frappe.get_cached_value("Company", self.company, "default_currency")
frappe.throw(
_(
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
).format(
frappe.bold(fmt_money(account_balance, currency=currency)),
frappe.bold(fmt_money(stock_value, currency=currency)),
frappe.bold(formatdate(self.period_end_date)),
),
title=_("Stock Value Mismatch"),
)
def get_stock_accounts_balance(self):
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
stock_accounts = (
frappe.qb.from_(account)
.select(account.name)
.where(
(account.account_type == "Stock")
& (account.company == self.company)
& (account.is_group == 0)
)
)
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit - gle.credit))
.where(
(gle.company == self.company)
& (gle.is_cancelled == 0)
& (gle.posting_date <= self.period_end_date)
& gle.account.isin(stock_accounts)
)
).run()
return flt(balance[0][0]) if balance else 0.0
def validate_stock_closing_entry(self):
closing_entry = frappe.db.get_value(
"Stock Closing Entry",
apply_unscoped_filters(
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
),
["name", "status", "modified"],
as_dict=True,
)
if not closing_entry:
frappe.throw(
_(
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry Required"),
)
if closing_entry.status != "Completed":
frappe.throw(
_(
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry In Progress"),
)
self.validate_stock_closing_entry_is_fresh(closing_entry)
def validate_stock_closing_entry_is_fresh(self, closing_entry):
sle = frappe.qb.DocType("Stock Ledger Entry")
last_change = (
frappe.qb.from_(sle)
.select(Max(sle.modified))
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
).run()
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
frappe.throw(
_(
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
title=_("Stock Closing Entry Outdated"),
)
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from frappe.utils import flt, today
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
@@ -386,6 +386,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
def test_stock_validations_before_period_closing(self):
from unittest.mock import patch
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
create_custom_fields(
{
"Stock Closing Entry": [
{
"fieldname": "warehouse",
"label": "Warehouse",
"fieldtype": "Link",
"options": "Warehouse",
}
]
}
)
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
se = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": pcv.period_start_date,
"to_date": pcv.period_end_date,
"warehouse": "Stores - TPC",
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.db_set("status", "Completed")
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name},
["name", "stock_value_difference"],
as_dict=1,
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item(
"Test PCV Batch Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TPCVB.####",
},
)
se1 = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
make_stock_entry(
item_code=item.name,
qty=10,
rate=200,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-06-15",
batch_no=batch_no,
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
pcv.reload()
pcv.submit()
outward = make_stock_entry(
item_code=item.name,
qty=5,
from_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2022-04-01",
batch_no=batch_no,
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": outward.name, "is_cancelled": 0},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -750.0)
self.assertRaisesRegex(
frappe.ValidationError,
"frozen",
make_stock_entry,
item_code=item.name,
qty=1,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
def test_period_closing_blocks_stale_stock_closing_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
make_stock_entry(
item_code=item.name,
qty=5,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def make_completed_stock_closing_entry(self, from_date, to_date):
from unittest.mock import patch
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": from_date,
"to_date": to_date,
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
return sce
def rebuild_stock_closing_balance(self, sce):
sce.remove_stock_closing()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -259,6 +259,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -888,7 +889,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -240,10 +240,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
unblock_invoice() {
const me = this;
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
args: { name: me.frm.doc.name },
callback: (r) => me.frm.reload_doc(),
me.frm.call("unblock_invoice", null, () => {
me.frm.reload_doc();
});
}
@@ -294,15 +292,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.dialog.set_primary_action(__("Save"), function () {
const dialog_data = me.dialog.get_values();
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
args: {
name: me.frm.doc.name,
me.frm.call(
"block_invoice",
{
hold_comment: dialog_data.hold_comment,
release_date: dialog_data.release_date,
},
callback: (r) => me.frm.reload_doc(),
});
() => {
me.frm.reload_doc();
}
);
me.dialog.hide();
});
@@ -341,10 +340,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
set_release_date(data) {
return frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
args: data,
callback: (r) => this.frm.reload_doc(),
const me = this;
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
me.frm.reload_doc();
});
}

View File

@@ -360,6 +360,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.on_hold",
"depends_on": "eval:doc.on_hold",
"fieldname": "sb_14",
"fieldtype": "Section Break",
"label": "Hold Invoice"
@@ -1694,7 +1695,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-07-12 23:54:21.263951",
"modified": "2026-08-05 15:40:16.519774",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
@@ -306,6 +306,9 @@ class PurchaseInvoice(BuyingController):
PurchaseTaxWithholding(self).on_validate()
self.set_percentage_received()
if self.on_hold:
self.validate_invoice_hold()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
@@ -317,6 +320,13 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
if self.docstatus < 1:
frappe.throw(_("Purchase Invoice can be held after submitting."))
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
frappe.throw(_("Release date must be in the future"))
@@ -820,14 +830,38 @@ class PurchaseInvoice(BuyingController):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
def block_invoice(self, hold_comment=None, release_date=None):
self.db_set("on_hold", 1)
self.db_set("hold_comment", cstr(hold_comment))
@frappe.whitelist(methods=["POST"])
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
self.on_hold = 1
self.release_date = release_date
self.validate_block_invoice()
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
@frappe.whitelist(methods=["POST"])
def unblock_invoice(self):
self.check_permission("write")
self.db_set({"on_hold": 0, "release_date": None})
@frappe.whitelist(methods=["POST"])
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
if not self.on_hold:
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
self.release_date = release_date
self.validate_block_invoice()
self.db_set("release_date", release_date)
def unblock_invoice(self):
self.db_set("on_hold", 0)
self.db_set("release_date", None)
def validate_block_invoice(self):
self.validate_invoice_hold()
if self.outstanding_amount <= 0:
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
self.validate_release_date()
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
@@ -925,24 +959,3 @@ def get_list_context(context=None):
@erpnext.allow_regional
def make_regional_gl_entries(gl_entries, doc):
return gl_entries
@frappe.whitelist()
def change_release_date(name: str, release_date: str | None = None):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()
def unblock_invoice(name: str):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.unblock_invoice()
@frappe.whitelist()
def block_invoice(name: str, release_date: str, hold_comment: str | None = None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)

View File

@@ -278,14 +278,166 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_purchase_invoice_explicit_block(self):
pi = make_purchase_invoice()
pi.block_invoice()
release_date = add_days(nowdate(), 10)
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
self.assertEqual(pi.on_hold, 1)
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
)
self.assertEqual(on_hold, 1)
self.assertEqual(hold_comment, "Waiting for the goods")
self.assertEqual(getdate(saved_release_date), getdate(release_date))
pi.unblock_invoice()
self.assertEqual(pi.on_hold, 0)
on_hold, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
)
self.assertEqual(on_hold, 0)
self.assertIsNone(saved_release_date)
def test_purchase_invoice_cannot_be_held_before_submission(self):
pi = make_purchase_invoice(do_not_save=True)
pi.on_hold = 1
self.assertRaises(frappe.ValidationError, pi.save)
pi.on_hold = 0
pi.save()
pi.submit()
pi.block_invoice()
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
def test_return_purchase_invoice_cannot_be_held(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.on_hold = 1
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
return_pi.on_hold = 0
return_pi.save()
return_pi.submit()
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
# a return has a negative outstanding amount, which must not be mistaken
# for an invalid hold on a document that was never held
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save()
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
self.assertEqual(return_pi.on_hold, 0)
self.assertLess(return_pi.outstanding_amount, 0)
def test_settled_purchase_invoice_cannot_be_held(self):
pi = make_purchase_invoice()
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.save()
pe.submit()
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
self.assertRaises(frappe.ValidationError, pi.block_invoice)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_release_date_of_held_invoice_must_be_in_future(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
def test_rejected_hold_does_not_partially_update_invoice(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
pi.reload()
self.assertEqual(pi.on_hold, 0)
self.assertIsNone(pi.release_date)
def test_change_release_date_of_held_invoice(self):
pi = make_purchase_invoice()
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
new_release_date = add_days(nowdate(), 20)
pi.change_release_date(new_release_date)
self.assertEqual(
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
getdate(new_release_date),
)
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
pi = make_purchase_invoice()
self.assertRaisesRegex(
frappe.ValidationError,
"Invoice is not blocked",
pi.change_release_date,
add_days(nowdate(), 10),
)
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
def test_hold_methods_are_whitelisted_document_methods(self):
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
pi = frappe.new_doc("Purchase Invoice")
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
# raises if the method is not whitelisted for client side calls
pi.is_whitelisted(method)
self.assertFalse(
hasattr(purchase_invoice_module, method),
f"{method} should only be exposed as a document method",
)
def test_hold_methods_require_write_permission(self):
pi = make_purchase_invoice()
user = "test_pi_hold_permission@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Test PI Hold",
"roles": [{"role": "Employee"}],
}
).insert(ignore_permissions=True)
frappe.set_user(user)
try:
self.assertRaises(frappe.PermissionError, pi.block_invoice)
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
finally:
frappe.set_user("Administrator")
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",

View File

@@ -241,6 +241,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1032,7 +1033,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -587,7 +587,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
super.set_dynamic_labels();
this.frm.events.hide_fields(this.frm);
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
// metadata, so this always reflects the original (Customize Form) hidden value.
const hidden_by_customization = cint(
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
);
this.frm.set_df_property("update_stock", "hidden", hide_update_stock || hidden_by_customization);
}
items_on_form_rendered() {

View File

@@ -1165,6 +1165,7 @@ class SalesInvoice(SellingController):
child_tables = {
"items": ("income_account", "expense_account", "discount_account"),
"taxes": ("account_head",),
"payments": ("account",),
}
self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
if self.needs_repost:

View File

@@ -114,6 +114,14 @@ class TestSalesInvoice(ERPNextTestSuite):
si.save()
self.assertEqual(si.items[0].qty, 1)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
invoice, since invoices post to the GL (unlike Sales Order)."""
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
self.assertRaises(frappe.ValidationError, si.save)
def test_timestamp_change(self):
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
w.docstatus = 0

View File

@@ -249,6 +249,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -1066,7 +1067,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2016-05-08 23:49:38.842621",
"doctype": "DocType",
"editable_grid": 1,
@@ -17,6 +18,7 @@
],
"fields": [
{
"allow_on_submit": 1,
"fieldname": "mode_of_payment",
"fieldtype": "Link",
"in_list_view": 1,
@@ -39,6 +41,7 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "account",
"fieldtype": "Link",
"label": "Account",
@@ -47,6 +50,7 @@
"read_only": 1
},
{
"allow_on_submit": 1,
"fetch_from": "mode_of_payment.type",
"fieldname": "type",
"fieldtype": "Read Only",
@@ -85,7 +89,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-02-16 20:46:34.592604",
"modified": "2026-07-29 16:44:54.482826",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Payment",

View File

@@ -161,7 +161,14 @@ class ShippingRule(Document):
)
shipping_charge["add_deduct_tax"] = "Add"
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
shipping_charge_filters = shipping_charge.copy()
if not self.cost_center:
shipping_charge_filters["cost_center"] = (
"in",
(None, "", erpnext.get_default_cost_center(doc.company)),
)
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
if existing_shipping_charge:
# take the last record found
existing_shipping_charge[-1].tax_amount = shipping_amount

View File

@@ -278,6 +278,9 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
if self.status == STATUS_CANCELLED:
return
self._set_current_invoice_dates()
if self.is_trialling():
self.status = STATUS_TRIALING
@@ -673,7 +676,7 @@ class Subscription(Document):
if self.cancel_at_period_end and (
getdate(posting_date) >= getdate(self.next_billing_period_end)
or getdate(posting_date) >= getdate(self.end_date)
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
):
self.cancel_subscription()

View File

@@ -779,6 +779,38 @@ class TestSubscription(ERPNextTestSuite):
subscription.reload()
self.assertEqual(subscription.status, "Active")
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
# https://github.com/frappe/erpnext/issues/57761
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
cancel_at_period_end=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
self.assertGreater(invoice.outstanding_amount, 0)
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
cancelation_date = getdate(subscription.cancelation_date)
self.assertIsNotNone(cancelation_date)
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
payment_entry.reference_no = "12345"
payment_entry.reference_date = nowdate()
payment_entry.submit()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
invoice_count = len(subscription.invoices)
subscription.process()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoice_count)
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
start_date=nowdate(),

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 16:13:49.623613",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account (Standard)",
"name": "Party Account - Accounts",
"owner": "Administrator"
}

View File

@@ -27,6 +27,6 @@
"modified": "2026-07-10 11:26:57.841200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry (Standard)",
"name": "Payment Entry - Accounts",
"owner": "Administrator"
}

View File

@@ -71,6 +71,6 @@
"modified": "2026-07-20 15:56:46.025286",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice (Standard)",
"name": "Purchase Invoice - Accounts",
"owner": "Administrator"
}

View File

@@ -63,6 +63,6 @@
"modified": "2026-07-20 15:32:43.080034",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice (Standard)",
"name": "Sales Invoice - Accounts",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 15:08:57.487184",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription (Standard)",
"name": "Subscription - Accounts",
"owner": "Administrator"
}

View File

@@ -865,9 +865,11 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
if not frappe.has_permission(doctype, "read"):
return None
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]

View File

@@ -24,6 +24,11 @@ class TestAccountBalance(ERPNextTestSuite):
"currency": "EUR",
"balance": -100.0,
},
{
"account": "Exchange Gain - _TC2",
"currency": "EUR",
"balance": 0.0,
},
{
"account": "Income - _TC2",
"currency": "EUR",

View File

@@ -234,20 +234,36 @@ function create_payment_entries_from_payable_report(report) {
return;
}
// build per-(supplier, party_account) summary to match backend grouping key
// validate against live state: only unpaid/partly-paid invoices with real outstanding are payable
frappe.call({
method: "erpnext.accounts.bulk_payment.get_payable_invoices",
args: { invoices: rows.map((r) => ({ voucher_no: r.voucher_no })) },
callback: ({ message }) => {
const { payable = [], excluded = [], currency } = message || {};
if (!payable.length) {
frappe.msgprint(__("None of the selected invoices are payable"));
return;
}
show_create_payment_entries_dialog(report, payable, excluded, currency);
},
});
}
function show_create_payment_entries_dialog(report, payable, excluded, currency) {
// group by (supplier, party_account) for the overview — matches the backend grouping key
const supplierMap = {};
for (const r of rows) {
const key = `${r.party}||${r.party_account}`;
for (const inv of payable) {
const key = `${inv.supplier}||${inv.party_account}`;
if (!supplierMap[key]) {
supplierMap[key] = {
supplier: r.party,
party_account: r.party_account,
supplier: inv.supplier,
party_account: inv.party_account,
count: 0,
outstanding: 0,
};
}
supplierMap[key].count += 1;
supplierMap[key].outstanding += r.outstanding || 0;
supplierMap[key].outstanding += inv.outstanding || 0;
}
const overviewFields = [
@@ -284,24 +300,36 @@ function create_payment_entries_from_payable_report(report) {
},
];
const fields = [];
if (excluded.length) {
fields.push({ fieldtype: "HTML", fieldname: "excluded_note", options: excluded_note_html(excluded) });
}
fields.push({
fieldname: "supplier_overview",
fieldtype: "Table",
label: __("Supplier Overview"),
cannot_add_rows: true,
cannot_delete_rows: true,
fields: overviewFields,
data: Object.values(supplierMap).map((d) => ({
supplier: d.supplier,
party_account: d.party_account,
invoices: d.count,
payable_amount: d.outstanding,
})),
});
const pe_count = Object.keys(supplierMap).length;
const grand_total = Object.values(supplierMap).reduce((sum, d) => sum + d.outstanding, 0);
fields.push({
fieldtype: "HTML",
fieldname: "summary_footer",
options: summary_footer_html(pe_count, grand_total, currency),
});
const dialog = new frappe.ui.Dialog({
title: __("Create Payment Entries"),
fields: [
{
fieldname: "supplier_overview",
fieldtype: "Table",
label: __("Supplier Overview"),
cannot_add_rows: true,
cannot_delete_rows: true,
fields: overviewFields,
data: Object.values(supplierMap).map((d) => ({
supplier: d.supplier,
party_account: d.party_account,
invoices: d.count,
payable_amount: d.outstanding,
})),
},
],
fields: fields,
primary_action_label: __("Create"),
secondary_action_label: __("Cancel"),
secondary_action() {
@@ -311,32 +339,15 @@ function create_payment_entries_from_payable_report(report) {
primary_action() {
dialog.hide();
const groupedKeys = new Set(
Object.values(supplierMap)
.filter((d) => d.count > 1)
.map((d) => `${d.supplier}||${d.party_account}`)
);
const grouped_invoices = [];
const ungrouped_invoices = [];
for (const r of rows) {
const payload = {
voucher_no: r.voucher_no,
supplier: r.party,
party_account: r.party_account,
};
(groupedKeys.has(`${r.party}||${r.party_account}`)
? grouped_invoices
: ungrouped_invoices
).push(payload);
}
// backend re-derives supplier/party_account and grouping from live data
const invoices = payable.map((inv) => ({ voucher_no: inv.voucher_no }));
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
frappe
.call({
method: "erpnext.accounts.bulk_payment.create_payment_entries",
args: { grouped_invoices, ungrouped_invoices },
args: { invoices },
})
.then(clearSelection)
.catch(clearSelection);
@@ -345,6 +356,42 @@ function create_payment_entries_from_payable_report(report) {
dialog.show();
}
function summary_footer_html(pe_count, grand_total, currency) {
return `<div style="
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--margin-sm);
font-size: var(--text-sm);
">
<span class="text-muted">${__("Payment Entries are created as drafts for your review")}</span>
<span>${__("{0} Payment Entries", [pe_count])} ·
<strong>${format_currency(grand_total, currency)}</strong></span>
</div>`;
}
function excluded_note_html(excluded) {
const counts = {};
for (const e of excluded) {
counts[e.reason] = (counts[e.reason] || 0) + 1;
}
const summary = Object.entries(counts)
.map(([reason, n]) => `${n} ${reason}`)
.join(", ");
return `<div style="
background-color: var(--bg-yellow);
color: var(--text-on-yellow);
font-size: var(--text-sm);
border-radius: var(--border-radius);
padding: var(--padding-sm) var(--padding-md);
margin-bottom: var(--margin-sm);
">
<span style="font-weight: var(--weight-medium);">${__("{0} invoice(s) excluded", [
excluded.length,
])}</span>: ${frappe.utils.escape_html(summary)}
</div>`;
}
erpnext.utils.add_dimensions("Accounts Payable", 10);
function get_party_type_options() {

View File

@@ -553,8 +553,8 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
else:
invoice_expense_map[d.parent][d.account_head] = flt(d.tax_amount)
else:
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, [])
invoice_tax_map[d.parent][d.account_head] = flt(d.tax_amount)
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, 0.0)
invoice_tax_map[d.parent][d.account_head] += flt(d.tax_amount)
return invoice_expense_map, invoice_tax_map

View File

@@ -47,6 +47,41 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
def test_add_and_deduct_rows_on_one_account_are_netted(self):
"""An account head carrying both an Add and a Deduct row must report their net.
The tax query groups by (parent, account_head, add_deduct_tax), so such an account comes
back as two rows. Only one of them survived into the report.
"""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
make_purchase_invoice as make_pi,
)
company = "_Test Company"
tax_account = "_Test Account VAT - _TC"
pi = make_pi(company=company, do_not_save=True)
for add_deduct, amount in (("Add", 10), ("Deduct", 4)):
pi.append(
"taxes",
{
"charge_type": "Actual",
"account_head": tax_account,
"description": "VAT",
"category": "Total",
"add_deduct_tax": add_deduct,
"tax_amount": amount,
"cost_center": "Main - _TC",
},
)
pi.save()
pi.submit()
filters = frappe._dict(company=company, from_date=add_months(today(), -1), to_date=today())
row = next(r for r in execute(filters)[1] if r.get("voucher_no") == pi.name)
self.assertEqual(flt(row.get(frappe.scrub(tax_account))), 6.0)
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
from frappe.utils import cstr
@@ -128,25 +128,47 @@ def get_pos_invoice_data(filters):
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
t1 = (
# t1: one row per invoice with the summed item base_total. warehouse and cost_center describe an
# item line, not the invoice, and an invoice may carry several. warehouse then becomes an outer
# grouping key below, so which line wins decides how rows are partitioned and what each row totals
# -- not merely which label is shown. Max() over text is a sort, and MariaDB (case-folding) and
# PostgreSQL (byte order) resolve it differently, so take both off one real line instead.
# The representative is the first line the user entered: Min(idx) is an integer, so the pick is
# free of collation and is meaningful, rather than turning on an unrelated hash-named row.
grouped_items = (
frappe.qb.from_(sii)
.select(
sii.parent,
Sum(sii.amount).as_("base_total"),
Max(sii.warehouse).as_("warehouse"),
Max(sii.cost_center).as_("cost_center"),
)
.select(sii.parent, Sum(sii.amount).as_("base_total"), Min(sii.idx).as_("representative_idx"))
.groupby(sii.parent)
).as_("grouped_items")
representative_item = frappe.qb.DocType("Sales Invoice Item").as_("representative_item")
t1 = (
frappe.qb.from_(grouped_items)
.inner_join(representative_item)
.on(
(representative_item.parent == grouped_items.parent)
& (representative_item.idx == grouped_items.representative_idx)
)
.select(
grouped_items.parent,
grouped_items.base_total,
representative_item.warehouse,
representative_item.cost_center,
)
)
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
# t3: mode_of_payment per invoice, from one real payment line for the same reason
grouped_payments = (
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
).as_("grouped_payments")
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
t3 = (
frappe.qb.from_(sip)
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
.groupby(sip.parent)
frappe.qb.from_(grouped_payments)
.inner_join(representative_payment)
.on(
(representative_payment.parent == grouped_payments.parent)
& (representative_payment.idx == grouped_payments.representative_idx)
)
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns

View File

@@ -54,6 +54,53 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
self.assertIn("Credit Card", next(iter(mop.values())))
self.assertNotIn("Cash", next(iter(mop.values())))
def test_pos_invoice_warehouse_and_cost_center_come_from_one_item(self):
"""The reported warehouse and cost centre must belong to the same item line.
They describe a line, not the invoice, and an invoice can carry several. Aggregating each
on its own can report a warehouse from one line beside a cost centre from another -- a pair
that was never posted. The warehouse is also an outer grouping key, so the pick decides how
rows are partitioned and what each one totals, not just what is displayed.
"""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
low_warehouse = create_warehouse("_Test POS Summary AAA")
high_warehouse = create_warehouse("_Test POS Summary ZZZ")
second_item = make_item("_Test POS Summary Second Item", {"is_stock_item": 0}).name
si = create_sales_invoice_record()
si.is_pos = 1
# cross the two picks: the higher warehouse is on the line with the lower cost centre, so an
# independently aggregated pair cannot belong to either line
si.items[0].warehouse = high_warehouse
si.items[0].cost_center = "Main - _TC"
si.append(
"items",
{
"item_code": second_item,
"qty": 1,
"rate": 5000,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"warehouse": low_warehouse,
"cost_center": "Sub - _TC",
},
)
si.append("payments", {"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 15000})
si.insert()
si.submit()
posted = {(row.warehouse, row.cost_center) for row in si.items}
self.assertGreater(len(posted), 1, "fixture must post more than one distinct pair")
rows = get_pos_invoice_data(get_filters())
reported = [r for r in rows if r.get("warehouse") in {w for w, _ in posted}]
self.assertTrue(reported)
for row in reported:
self.assertIn((row["warehouse"], row["cost_center"]), posted)
def test_get_mode_of_payments_details(self):
filters = get_filters()

View File

@@ -160,7 +160,8 @@ def _execute(filters, additional_table_columns=None):
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
# credits the invoice itself posts to the receivable (mirrors its GL)
"credit": get_in_invoice_receivable_credit(inv),
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
@@ -181,6 +182,14 @@ def _execute(filters, additional_table_columns=None):
return columns, res, None, None, None, include_payments
def get_in_invoice_receivable_credit(inv):
# amount the invoice settles against its own receivable, matching the invoice's GL entries
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
return credit
def get_columns(invoice_list, additional_table_columns, include_payments=False):
"""return columns based on filters"""
columns = [
@@ -458,6 +467,11 @@ def get_invoices(filters, additional_query_columns):
si.base_net_total,
si.base_grand_total,
si.base_rounded_total,
si.is_pos,
si.base_paid_amount,
si.base_change_amount,
si.base_write_off_amount,
si.loyalty_amount,
si.outstanding_amount,
si.is_internal_customer,
si.represents_company,

View File

@@ -1,6 +1,7 @@
import frappe
from frappe.utils import add_days, flt, getdate, today
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -251,6 +252,46 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
self.assertDictEqual(result_output, expected_result)
def test_ledger_view_nets_pos_paid_invoice(self):
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
# and net to zero instead of showing a phantom outstanding.
make_pos_profile()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_save=1,
)
si.is_pos = 1
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si = si.save().submit()
self.assertEqual(flt(si.outstanding_amount), 0.0)
filters = frappe._dict(
{
"from_date": today(),
"to_date": today(),
"company": self.company,
"include_payments": True,
"customer": self.customer,
}
)
rows = execute(filters)[1]
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
self.assertEqual(flt(inv_row.get("debit")), 100.0)
self.assertEqual(flt(inv_row.get("credit")), 100.0)
# running balance is unchanged by a fully-paid POS invoice
idx = rows.index(inv_row)
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
def test_outstanding_currency_conversion(self):
foreign_invoice = create_sales_invoice(
customer="_Test Customer",

View File

@@ -16,6 +16,11 @@ from erpnext.stock.get_item_details import (
get_conversion_factor,
get_item_warehouse_,
)
from erpnext.stock.utils import (
is_group_warehouse,
validate_disabled_warehouse,
validate_warehouse_company,
)
class ChildItemUpdater:
@@ -340,7 +345,7 @@ def set_order_defaults(
child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
child_item.stock_uom = item.stock_uom
child_item.uom = trans_item.get("uom") or item.stock_uom
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype)
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
@@ -349,20 +354,44 @@ def set_order_defaults(
child_item.base_rate = 1
child_item.base_amount = 1
if child_doctype == "Sales Order Item":
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not child_item.warehouse:
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
).format(frappe.bold(item.item_code))
)
set_child_tax_template_and_map(item, child_item, p_doc)
add_taxes_from_tax_template(child_item, p_doc)
return child_item
def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None:
"""Return the warehouse picked in the Update Items dialog, else the configured default.
Validates whichever warehouse was resolved, since a submitted parent skips validate().
"""
warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not warehouse:
if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item):
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company."
).format(frappe.bold(item.item_code))
)
return None
validate_warehouse_company(warehouse, p_doc.company)
validate_disabled_warehouse(warehouse)
is_group_warehouse(warehouse)
return warehouse
def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool:
"""Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse."""
if child_doctype == "Sales Order Item":
return True
if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier)
return False
def validate_child_on_delete(row, parent, ordered_item=None) -> None:
"""Raise if a partially transacted child item is being deleted."""
if parent.doctype == "Sales Order":

View File

@@ -11,6 +11,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision
def get_exchange_gain_loss_account(company: str, is_gain: bool) -> str | None:
fieldname = "exchange_gain_account" if is_gain else "exchange_loss_account"
return frappe.get_cached_value("Company", company, fieldname) or frappe.get_cached_value(
"Company", company, "exchange_gain_loss_account"
)
def gain_loss_journal_already_booked(
gain_loss_account: str,
exc_gain_loss: float,
@@ -163,9 +170,7 @@ def make_exchange_gain_loss_journal(
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gain_loss_account = frappe.get_cached_value(
"Company", doc.company, "exchange_gain_loss_account"
)
gain_loss_account = get_exchange_gain_loss_account(doc.company, reverse_dr_or_cr == "credit")
je = create_gain_loss_journal(
doc.company,
args.get("difference_posting_date") if args else doc.posting_date,
@@ -195,7 +200,7 @@ def make_exchange_gain_loss_journal(
def is_payable_account(reference_doctype: str, account: str) -> bool:
if reference_doctype == "Purchase Invoice" or (
reference_doctype == "Journal Entry"
reference_doctype in ("Journal Entry", "Payment Entry")
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
):
return True

View File

@@ -2379,13 +2379,16 @@ class QueryPaymentLedger:
)
# build query for voucher amount
query_voucher_amount = (
# account is grouped, not aggregated: it is a join key against the outstanding CTE below, and
# it fixes the currency the amounts are summed in. The two CTEs aggregate over different row
# sets, so two Max() picks could disagree and the join would silently miss, leaving the
# outstanding NULL. posting_date/due_date are dates, so Max() there cannot depend on
# collation. cost_center and remarks are free text that genuinely varies per row, so they
# come off one real row instead -- see representative below.
grouped_voucher_amount = (
qb.from_(ple)
.select(
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
# every non-aggregated column to be grouped or aggregated)
Max(ple.account).as_("account"),
ple.account,
ple.voucher_type,
ple.voucher_no,
ple.party_type,
@@ -2393,25 +2396,47 @@ class QueryPaymentLedger:
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Max(ple.cost_center).as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
Max(ple.remarks).as_("remarks"),
Min(ple.name).as_("representative"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
.where(Criterion.all(self.common_filter))
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
).as_("grouped")
# Payment Ledger Entry has no autoname rule, so frappe names it by hash -- lower-case, which
# keeps Min(name) free of the collation divergence that picking Max() over free text has.
representative_ple = qb.DocType("Payment Ledger Entry").as_("representative_ple")
query_voucher_amount = (
qb.from_(grouped_voucher_amount)
.inner_join(representative_ple)
.on(representative_ple.name == grouped_voucher_amount.representative)
.select(
grouped_voucher_amount.account,
grouped_voucher_amount.voucher_type,
grouped_voucher_amount.voucher_no,
grouped_voucher_amount.party_type,
grouped_voucher_amount.party,
grouped_voucher_amount.posting_date,
grouped_voucher_amount.due_date,
grouped_voucher_amount.currency,
grouped_voucher_amount.amount,
grouped_voucher_amount.amount_in_account_currency,
representative_ple.cost_center.as_("cost_center"),
representative_ple.remarks.as_("remarks"),
)
)
# build query for voucher outstanding
query_voucher_outstanding = (
qb.from_(ple)
.select(
# Max() on columns constant per group keeps this valid on postgres (see above)
Max(ple.account).as_("account"),
# grouped, not aggregated: this is the other side of the join key -- see above
ple.account,
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
ple.party_type,
@@ -2425,7 +2450,7 @@ class QueryPaymentLedger:
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_against_voucher_no))
.where(Criterion.all(self.common_filter))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.groupby(ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
)
# build CTE for combining voucher amount and outstanding

View File

@@ -116,24 +116,39 @@ frappe.ui.form.on("Asset Repair", {
},
repair_status: (frm) => {
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
}
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
frm.set_value("completion_date", frappe.datetime.now_datetime());
}
frm.events.set_downtime(frm);
},
failure_date: (frm) => {
frm.events.set_downtime(frm);
},
completion_date: (frm) => {
frm.events.set_downtime(frm);
},
set_downtime: (frm) => {
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
frm.set_value("downtime", null);
return;
}
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
},
stock_items_on_form_rendered() {

View File

@@ -67,6 +67,7 @@ class AssetRepair(AccountsController):
self.calculate_repair_cost()
self.calculate_total_repair_cost()
self.check_repair_status()
self.set_downtime()
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Scrapped"):
@@ -239,6 +240,13 @@ class AssetRepair(AccountsController):
if self.repair_status == "Pending" and self.docstatus == 1:
frappe.throw(_("Please update Repair Status."))
def set_downtime(self):
# keep downtime in sync with the entered dates, regardless of edit order
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
else:
self.downtime = None
def update_asset_value(self):
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost

View File

@@ -98,6 +98,21 @@ class TestAssetRepair(ERPNextTestSuite):
asset_repair = create_asset_repair(submit=1)
self.assertNotEqual(asset_repair.repair_status, "Pending")
def test_downtime_stays_in_sync_with_dates(self):
asset = create_asset(submit=1)
asset_repair = create_asset_repair(asset=asset)
asset_repair.failure_date = "2026-07-31 09:00:00"
asset_repair.completion_date = "2026-07-31 11:00:00"
asset_repair.repair_status = "Completed"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
# editing a date must refresh downtime, not leave a stale value
asset_repair.completion_date = "2026-07-31 14:30:00"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
def test_stock_items(self):
asset_repair = create_asset_repair(stock_consumption=1)
self.assertTrue(asset_repair.stock_consumption)

View File

@@ -310,12 +310,45 @@ class PurchaseOrder(BuyingController):
itemwise_qty.setdefault(d.item_code, 0)
itemwise_qty[d.item_code] += flt(d.stock_qty)
precision = self.items[0].precision("stock_qty")
for item_code, qty in itemwise_qty.items():
if flt(qty) < flt(itemwise_min_order_qty.get(item_code)):
if flt(qty, precision) < flt(itemwise_min_order_qty.get(item_code), precision):
frappe.throw(
_(
"Item {0}: Ordered qty {1} cannot be less than minimum order qty {2} (defined in Item)."
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
)
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
if not self.is_new():
return
precision = self.items[0].precision("stock_qty")
itemwise_step = frappe._dict()
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
overage = flt(qty) - min_order_qty
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
frappe.toast(
_(
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
).format(
item_code,
flt(qty, precision),
itemwise_stock_uom[item_code],
min_order_qty,
flt(overage, precision),
),
indicator="orange",
)
def get_schedule_dates(self):

View File

@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.save()
self.assertEqual(po.items[1].qty, 1)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
self.assertRaises(frappe.ValidationError, po.save)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
po.save()
po.submit()
self.assertEqual(po.docstatus, 1)
self.assertTrue(po.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
self.assertRaises(frappe.ValidationError, po.save)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
@@ -216,6 +238,21 @@ class TestPurchaseOrder(ERPNextTestSuite):
po2.items[0].qty = 110
self.assertRaises(OverAllowanceError, po2.submit)
# Stock over-delivery role must not bypass over-ordering against Material Request.
with self.change_settings(
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
):
test_user = frappe.get_doc("User", "test@example.com")
test_user.add_roles("Stock Manager")
mr3 = make_material_request(qty=100)
po3 = make_purchase_order(mr3.name)
po3.supplier = "_Test Supplier"
po3.items[0].qty = 110
with self.set_user("test@example.com"):
po3.flags.ignore_permissions = True
self.assertRaises(OverAllowanceError, po3.submit)
# cleanup
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
@@ -305,6 +342,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db()
existing_ordered_qty = get_ordered_qty()
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
first_item_of_po = po.get("items")[0]
trans_item = json.dumps(
@@ -315,16 +353,62 @@ class TestPurchaseOrder(ERPNextTestSuite):
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": "_Test Item", "rate": 200, "qty": 7},
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
po.reload()
self.assertEqual(len(po.get("items")), 2)
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
self.assertEqual(po.status, "To Receive and Bill")
# ordered qty should increase on row addition
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
# ordered qty should increase on row addition, in the warehouse passed for the new row
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
self.assertEqual(
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
existing_ordered_qty_in_new_warehouse + 7,
)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
po = create_purchase_order(do_not_save=1)
po.save()
po.submit()
first_item_of_po = po.get("items")[0]
company_default = frappe.db.get_value("Company", po.company, "default_warehouse")
frappe.db.set_value("Company", po.company, "default_warehouse", None)
self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default)
def get_trans_items(item_code):
return json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": item_code, "rate": 200, "qty": 7},
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Purchase Order",
get_trans_items(stock_item),
po.name,
)
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
po.reload()
self.assertEqual(po.get("items")[-1].item_code, service_item)
self.assertFalse(po.get("items")[-1].warehouse)
def test_update_child_removing_item(self):
po = create_purchase_order(do_not_save=1)
@@ -692,6 +776,66 @@ class TestPurchaseOrder(ERPNextTestSuite):
po = create_purchase_order(qty=3.4, do_not_save=True)
self.assertRaises(UOMMustBeIntegerError, po.insert)
def test_min_order_qty_with_uom_conversion_dust(self):
item_doc = make_item(properties={"min_order_qty": 2000, "stock_uom": "Kg"})
item_doc.append("uoms", {"uom": "Litre", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Litre"
po.items[0].conversion_factor = 0.6
po.insert()
below_minimum = create_purchase_order(item_code=item, qty=3000, do_not_save=1)
below_minimum.items[0].uom = "Litre"
below_minimum.items[0].conversion_factor = 0.6
self.assertRaises(frappe.ValidationError, below_minimum.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
def test_uom_integer_check_tolerates_conversion_dust(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
item_doc = make_item(properties={"stock_uom": "Nos"})
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Kg"
po.items[0].conversion_factor = 0.6
po.insert()
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
fractional.items[0].uom = "Kg"
fractional.items[0].conversion_factor = 0.6
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
def test_ordered_qty_for_closing_po(self):
bin = frappe.get_all(
"Bin",
@@ -1044,6 +1188,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi)
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.mapper import (
@@ -1055,9 +1201,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
)
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
prepare_data_for_internal_transfer()
supplier = "_Test Internal Supplier 2"
@@ -1496,6 +1639,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed")
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",

View File

@@ -260,6 +260,7 @@
"label": "UOM Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"print_width": "100px",
"reqd": 1,
@@ -943,7 +944,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:30:04.600510",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -132,6 +132,7 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"read_only": 1
},
{
@@ -207,7 +208,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:26.235916",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Receipt Item Supplied",

View File

@@ -324,14 +324,14 @@ class RequestforQuotation(BuyingController):
message_template = self.mfs_html if self.use_html else self.message_for_supplier
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
rendered_message = frappe.render_template(message_template, doc_args)
rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation")
)
rendered_subject = frappe.render_template(subject_source, doc_args)
rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True)
if preview:
return {
"message": rendered_message,

View File

@@ -241,6 +241,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -274,7 +275,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-15 00:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -217,6 +217,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -614,7 +615,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:33:24.855979",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -47,6 +47,6 @@
"modified": "2026-07-20 15:54:26.047600",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order (Standard)",
"name": "Purchase Order - Buying",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-03 17:18:03.006829",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation (Standard)",
"name": "Request for Quotation - Buying",
"owner": "Administrator"
}

View File

@@ -15,6 +15,6 @@
"modified": "2026-07-03 17:14:32.891939",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation (Standard)",
"name": "Supplier Quotation - Buying",
"owner": "Administrator"
}

View File

@@ -51,7 +51,6 @@ def get_data(filters):
mr_item.item_code.as_("item_code"),
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
Max(Coalesce(mr_item.uom, "")).as_("uom"),
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
@@ -60,8 +59,6 @@ def get_data(filters):
),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
Max(mr_item.item_name).as_("item_name"),
Max(mr_item.description).as_("description"),
Max(mr.company).as_("company"),
)
.where(
@@ -75,8 +72,34 @@ def get_data(filters):
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
data = query.run(as_dict=True)
return data
rows = query.run(as_dict=True)
apply_representative_lines(rows)
return rows
def apply_representative_lines(rows):
"""Fill item_name/description/uom from one real Material Request Item line per group.
All three are editable per line, so a request listing the same item twice holds several values
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
value, so the engines pick differently. Take the first line by idx.
"""
material_requests = list({row.material_request for row in rows})
representative = {}
if material_requests:
for line in frappe.get_all(
"Material Request Item",
filters={"parent": ("in", material_requests), "docstatus": 1},
fields=["parent", "item_code", "item_name", "description", "uom"],
order_by="idx",
):
representative.setdefault((line.parent, line.item_code), line)
for row in rows:
line = representative.get((row.material_request, row.item_code))
row.item_name = line.item_name if line else None
row.description = line.description if line else None
row.uom = line.uom if line else ""
def get_conditions(filters, query, mr, mr_item):

View File

@@ -210,6 +210,23 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
def is_negative_grand_total_allowed(self) -> bool:
"""Return True if this document may save with a negative grand total.
Sales Order and Purchase Order never post to the GL, so a negative
total is safe there whenever the user has explicitly opted into
negative rates via Selling/Buying Settings. Every other
AccountsController doctype (invoices, delivery notes, receipts,
quotations, ...) keeps relying on the `is_return` escape hatch only.
"""
if self.doctype == "Sales Order":
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
if self.doctype == "Purchase Order":
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
return False
def validate(self):
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -262,7 +279,8 @@ class AccountsController(TransactionBase):
self.calculate_taxes_and_totals()
if not self.meta.get_field("is_return") or not self.is_return:
self.validate_value("base_grand_total", ">=", 0)
if not self.is_negative_grand_total_allowed():
self.validate_value("base_grand_total", ">=", 0)
validate_return(self)
@@ -1039,9 +1057,16 @@ class AccountsController(TransactionBase):
party_account = self.credit_to
dr_or_cr = "debit_in_account_currency"
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
lst = []
for d in self.get("advances"):
if flt(d.allocated_amount) > 0:
is_gain = (
flt(d.get("exchange_gain_loss")) > 0
if party_type == "Customer"
else flt(d.get("exchange_gain_loss")) < 0
)
args = frappe._dict(
{
"voucher_type": d.reference_type,
@@ -1068,9 +1093,7 @@ class AccountsController(TransactionBase):
else self.grand_total
),
"outstanding_amount": self.outstanding_amount,
"difference_account": frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
),
"difference_account": get_exchange_gain_loss_account(self.company, is_gain),
"exchange_gain_loss": flt(d.get("exchange_gain_loss")),
"difference_posting_date": d.get("difference_posting_date"),
}

View File

@@ -466,7 +466,7 @@ class BuyingController(SubcontractingController):
self.precision("item_tax_amount", item),
)
self.round_floats_in(item)
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
if flt(item.conversion_factor) == 0.0:
item.conversion_factor = (
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0

View File

@@ -73,14 +73,17 @@ def employee_query(
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
.when(
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(txt_no_percent, Employee.employee_name) > 0,
Locate(txt_no_percent, Employee.employee_name),
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
)
.else_(99999)
)
@@ -136,17 +139,28 @@ def lead_query(
query.where(Lead.docstatus < 2)
.where(Lead.status.isnull() | (Lead.status != "Converted"))
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
.when(
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
.when(
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.company_name)),
)
.else_(99999)
)
.orderby(Lead.idx, order=Order.desc)
@@ -387,7 +401,12 @@ def bom(
.where(BOM.is_active == 1)
.where(BOM[searchfield].like(f"%{txt}%"))
.orderby(
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
Case()
.when(
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
Locate(Lower(txt_no_percent), Lower(BOM.name)),
)
.else_(99999)
)
.orderby(BOM.idx, order=Order.desc)
.orderby(BOM.name)

View File

@@ -160,10 +160,28 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
items_returned = True
if doc.doctype in (
"Purchase Invoice",
"Purchase Receipt",
"Subcontracting Receipt",
"Sales Invoice",
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
items_returned = True
else:
items_returned = True
elif d.item_name:
items_returned = True
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
# No item_code here means no linked Item, so there's no accepted/rejected
# split to speak of - received_qty isn't a meaningful independent signal.
# Only a negative qty (i.e. a real negative billing amount) counts.
if flt(d.qty) < 0:
items_returned = True
else:
items_returned = True
if not items_returned:
frappe.throw(_("At least one item should be entered with negative quantity in return document"))

View File

@@ -254,7 +254,7 @@ class SellingController(StockController):
total += sales_person.allocated_percentage
if sales_team and total != 100.0:
if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0:
throw(_("Total allocated percentage for sales team should be 100"))
def validate_sales_team(self, sales_team):

View File

@@ -265,6 +265,9 @@ class StatusUpdater(Document):
def validate_qty(self):
"""Validates qty at row level"""
selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note")
buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt")
for args in self.status_updater:
if "target_ref_field" not in args or args.get("validate_qty") is False:
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
@@ -292,11 +295,8 @@ class StatusUpdater(Document):
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be a negative number").format(d.item_code))
if (
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
) or (
not buying_negative_rate_allowed
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or (
not buying_negative_rate_allowed and self.doctype in buying_doctypes
):
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
frappe.throw(
@@ -307,7 +307,7 @@ class StatusUpdater(Document):
frappe.bold(_("`Allow Negative rates for Items`")),
get_link_to_form(
"Selling Settings"
if self.doctype in ["Sales Invoice", "Delivery Note"]
if self.doctype in selling_doctypes
else "Buying Settings"
),
),
@@ -446,11 +446,12 @@ class StatusUpdater(Document):
else (0, {}, None, None)
)
role_allowed_to_over_deliver_receive = frappe.get_single_value(
"Stock Settings", "role_allowed_to_over_deliver_receive"
)
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
role = None
if qty_or_amount == "qty":
if args.get("overflow_type") in ("delivery", "receipt"):
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
else:
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
overflow_percent = (
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]

View File

@@ -702,6 +702,11 @@ def is_reposting_pending():
)
def invalidate_future_sle_cache(voucher_type, voucher_no):
if hasattr(frappe.local, "future_sle"):
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
def future_sle_exists(args, sl_entries=None):
from erpnext.stock.utils import get_combine_datetime
@@ -888,7 +893,7 @@ def make_bundle_for_material_transfer(**kwargs):
row.stock_value_difference = abs(row.stock_value_difference)
if kwargs.type_of_transaction == "Outward":
row.qty *= -1
row.stock_value_difference *= row.stock_value_difference
row.stock_value_difference *= -1
row.is_outward = 1
row.warehouse = kwargs.warehouse

View File

@@ -225,7 +225,12 @@ class calculate_taxes_and_totals:
if self.doc.get("is_consolidated") or self.discount_amount_applied:
return
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
do_not_round_fields = [
"valuation_rate",
"incoming_rate",
"sales_incoming_rate",
"conversion_factor",
]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
self.calculate_item_rate(item)

View File

@@ -29,6 +29,27 @@ class TestQueries(ERPNextTestSuite):
self.assertGreaterEqual(len(query(txt="_Test Lead")), 4)
self.assertEqual(len(query(txt="_Test Lead 4")), 1)
def test_lead_query_ranking_is_case_insensitive(self):
"""A match at the start must rank first whatever its case.
The filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so both leads match.
Ranking used a bare Locate(), which becomes case-sensitive strpos() there: the upper-cased
lead scores no match, falls back to 99999 and sorts last, while MariaDB's case-insensitive
LOCATE ranks it first. Same query, different order -- and a different page when page_len is
small enough to cut between them.
"""
early, late = "ZZABCD Ranking Lead", "Ranking Lead zzabcd"
for lead_name in (early, late):
if not frappe.db.exists("Lead", {"lead_name": lead_name}):
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name}).insert()
query = add_default_params(queries.lead_query, "Lead")
names = [row[1] for row in query(txt="zzabcd")]
self.assertIn(early, names)
self.assertIn(late, names)
self.assertLess(names.index(early), names.index(late))
def test_item_query(self):
query = add_default_params(queries.item_query, "Item")

View File

@@ -37,3 +37,76 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
self.assertEqual(return_dn.is_return, 1)
self.assertEqual(return_dn.items[0].qty, -5)
def test_purchase_invoice_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
# item_name-only branch, which must also reject an all-zero-qty return instead
# of unconditionally treating the row as returned.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
pi.items[0].item_code = ""
pi.save()
pi.submit()
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
item_name="_Test Item",
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
return_pi.items[0].item_code = ""
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_delivery_note_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
dn = create_delivery_note(qty=5)
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_dn.insert)
def test_sales_invoice_zero_qty_return_is_rejected(self):
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
# every row must be rejected, not silently accepted as a no-op credit note.
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
si = create_sales_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
return_si = make_return_doc(si.doctype, si.name)
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)

View File

@@ -40,3 +40,140 @@ class TestStockControllerConversions(ERPNextTestSuite):
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
self.assertTrue(future_sle_exists(args, sl_entries))
def _make_opening_entry(self, item, warehouse):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
opening = make_stock_entry(
item_code=item,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(today(), -5),
posting_time="01:00:00",
)
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
return opening
def _later_sle(self, item, warehouse, opening):
sle = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": warehouse,
"posting_date": today(),
"posting_time": "12:00:00",
"voucher_type": "Stock Entry",
"voucher_no": opening.name,
"actual_qty": 7,
"incoming_rate": 100,
"qty_after_transaction": 107,
"valuation_rate": 100,
"stock_value": 10700,
"company": opening.company,
"stock_uom": "Nos",
}
)
sle.flags.ignore_permissions = True
sle.flags.ignore_links = True
return sle
def _submit_entry(self, item, warehouse, inject=None):
from erpnext.stock import stock_ledger
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
original_make_entry = stock_ledger.make_entry
injected = []
def make_entry_with_injection(*args, **kwargs):
if inject is not None and not injected:
injected.append(True)
inject.submit()
return original_make_entry(*args, **kwargs)
stock_ledger.make_entry = make_entry_with_injection
try:
entry = make_stock_entry(
item_code=item,
target=warehouse,
qty=5,
basic_rate=500,
posting_date=today(),
posting_time="06:00:00",
)
finally:
stock_ledger.make_entry = original_make_entry
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
if inject is not None:
self.assertTrue(injected, "the later SL Entry was not written during the submit")
return entry
def _reposts_queued_for(self, item, warehouse, voucher_no):
names = set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
pluck="name",
)
) | set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": voucher_no},
pluck="name",
)
)
for name in names:
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
return names
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
self._reposts_queued_for(item, warehouse, backdated.name),
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
)
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": backdated.name},
pluck="name",
),
"No voucher based Repost Item Valuation was queued",
)
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
self._make_opening_entry(item, warehouse)
entry = self._submit_entry(item, warehouse)
self.assertFalse(
self._reposts_queued_for(item, warehouse, entry.name),
"A Repost Item Valuation was queued for an entry with nothing posted after it",
)

View File

@@ -376,6 +376,41 @@ def get_period_month_ranges(period, fiscal_year):
return period_month_ranges
def quotation_party_name_expr():
"""Resolve a Quotation's party label from its dynamic link, mirroring set_customer_name()."""
customer_branch = (
"when t1.quotation_to = 'Customer' then "
"(select c.customer_name from `tabCustomer` c where c.name = t1.party_name)"
)
lead_branch = (
"when t1.quotation_to = 'Lead' then "
"(select coalesce(nullif(l.company_name, ''), l.lead_name) from `tabLead` l "
"where l.name = t1.party_name)"
)
prospect_branch = "when t1.quotation_to = 'Prospect' then t1.party_name"
branches = [customer_branch, lead_branch, prospect_branch]
# CRM Deal ships with the CRM app; skip the branch when its table is absent
if frappe.db.table_exists("CRM Deal"):
branches.append(
"when t1.quotation_to = 'CRM Deal' then "
"(select d.organization from `tabCRM Deal` d where d.name = t1.party_name)"
)
return "case " + " ".join(branches) + " end"
def quotation_territory_expr():
"""Only Customer and Lead carry a territory; other party types have none."""
return (
"case "
"when t1.quotation_to = 'Customer' then "
"(select c.territory from `tabCustomer` c where c.name = t1.party_name) "
"when t1.quotation_to = 'Lead' then "
"(select l.territory from `tabLead` l where l.name = t1.party_name) "
"end"
)
def based_wise_columns_query(based_on, trans):
based_on_details = {}
@@ -385,12 +420,14 @@ def based_wise_columns_query(based_on, trans):
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
]
# item_name is an editable per-line field, not functionally dependent on item_code, so it
# is aggregated (one row per item_code) rather than added to GROUP BY (which would split
# the row and change the MariaDB row count). See get_data's group-by query.
based_on_details["based_on_select"] = "t2.item_code, Max(t2.item_name) as item_name,"
based_on_details["based_on_group_by"] = "t2.item_code"
based_on_details["addl_tables"] = ""
# item_name is stored per line and editable, so it is not functionally dependent on item_code
# and Max() over it is a sort -- which MariaDB and PostgreSQL resolve differently. Read it
# from the Item master instead: that IS functionally dependent on the grouped item_code, so
# it can be grouped without splitting rows and is identical on both engines by construction.
based_on_details["based_on_select"] = "t2.item_code, item_master.item_name as item_name,"
based_on_details["based_on_group_by"] = "t2.item_code, item_master.item_name"
based_on_details["addl_tables"] = ",`tabItem` item_master"
based_on_details["addl_tables_relational_cond"] = " and t2.item_code = item_master.name"
elif based_on == "Item Group":
based_on_details["based_on_cols"] = [
@@ -425,9 +462,17 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "territory",
},
]
based_on_details[
"based_on_select"
] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
# a Quotation's party_name is a dynamic link, so no single master can be joined. Resolve
# it through the quotation_to discriminator, mirroring Quotation.set_customer_name, and
# group by it too: two parties of different types can share a name, and merging them
# under one row was never right. Correlated only on grouped columns, so the query stays
# valid under GROUP BY and free of any text sort.
based_on_details["based_on_select"] = (
f"t1.party_name, {quotation_party_name_expr()} as customer_name, "
f"{quotation_territory_expr()} as territory,"
)
based_on_details["based_on_group_by"] = "t1.party_name, t1.quotation_to"
based_on_details["addl_tables"] = ""
else:
based_on_details["based_on_cols"] = [
{
@@ -451,13 +496,19 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "territory",
},
]
# customer_name and territory are stored per transaction and editable, so they are not
# functionally dependent on the customer and Max() over them is a text sort, which the
# engines resolve differently. The Customer master's values ARE dependent on the grouped
# key, so they can be grouped without splitting rows and agree on both engines.
based_on_details["based_on_select"] = (
"t1.customer, customer_master.customer_name as customer_name, "
"customer_master.territory as territory,"
)
based_on_details[
"based_on_select"
] = "t1.customer, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
# territory (and customer_name) are not functionally dependent on the customer key, so they
# are aggregated rather than grouped — one row per customer, matching the prior MariaDB output.
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
based_on_details["addl_tables"] = ""
"based_on_group_by"
] = "t1.customer, customer_master.customer_name, customer_master.territory"
based_on_details["addl_tables"] = ",`tabCustomer` customer_master"
based_on_details["addl_tables_relational_cond"] = " and t1.customer = customer_master.name"
elif based_on == "Customer Group":
based_on_details["based_on_cols"] = [
@@ -490,14 +541,12 @@ def based_wise_columns_query(based_on, trans):
"fieldname": "supplier_group",
},
]
# supplier_name is a stored per-transaction field (not functionally dependent on supplier), so
# it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped
# by t1.supplier only. supplier_group comes from the joined master and is FD on supplier, so it
# stays in GROUP BY (postgres-valid, no row split).
based_on_details[
"based_on_select"
] = "t1.supplier, Max(t1.supplier_name) as supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_group"
# supplier_name is stored per transaction and editable, so Max() over it is a text sort that
# the engines resolve differently. The Supplier master is already joined here as t3 and its
# columns are functionally dependent on the grouped supplier, so both can simply be grouped:
# no row split, and identical on both engines by construction.
based_on_details["based_on_select"] = "t1.supplier, t3.supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_name, t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"

View File

@@ -13,6 +13,7 @@ from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from frappe.utils.html_utils import escape_html
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -269,7 +270,11 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
{
"note": escape_html(self.customer_details),
"added_by": frappe.session.user,
"added_on": now(),
},
)
self.party = lead.insert(ignore_permissions=True).name

View File

@@ -30,7 +30,7 @@ class ContractTemplate(Document):
def validate(self):
if self.contract_terms:
validate_template(self.contract_terms)
validate_template(self.contract_terms, restrict_globals=True)
@frappe.whitelist()
@@ -41,6 +41,6 @@ def get_contract_template(template_name: str, doc: str | dict | Document):
contract_terms = None
if contract_template.contract_terms:
contract_terms = frappe.render_template(contract_template.contract_terms, doc)
contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True)
return {"contract_template": contract_template, "contract_terms": contract_terms}

View File

@@ -171,8 +171,8 @@ def send_mail(entry, email_campaign):
context = {"doc": frappe.get_doc("Email Group", recipient)}
# Render template
subject = frappe.render_template(email_template.get("subject"), context)
content = frappe.render_template(email_template.response_, context)
subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True)
content = frappe.render_template(email_template.response_, context, restrict_globals=True)
frappe.db.savepoint("email_campaign_send")
try:

View File

@@ -133,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -143,6 +144,15 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
def validate_qty(self):
for item in self.items:
if flt(item.qty) <= 0:
frappe.throw(
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
item.idx, item.item_code
)
)
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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