Compare commits

...

67 Commits

Author SHA1 Message Date
Sudharsanan Ashok
6e0ec5af31 fix(regional): report uae vat 201 sales vat in company currency (#59168) 2026-09-24 01:05:37 +05:30
Vishnu Priya Baskaran
f7d16fbc16 fix: round running allocation balance in payment reconciliation (#58393) 2026-09-24 00:56:06 +05:30
Vishnu Priya Baskaran
2a7c7f5508 fix(pos): reset stale selected mode of payment on new invoice (#59308) 2026-09-24 00:54:51 +05:30
Shllokkk
0e322e457e Merge pull request #59349 from Shllokkk/mrp-po-missing-supplier
fix: throw clear message when MRP purchase item has no default supplier
2026-09-23 22:30:35 +05:30
Mihir Kandoi
775b019c30 feat(manufacturing): close individual blanket order items (#59343)
* feat(manufacturing): show stock UOM on blanket order items

A Blanket Order row's qty is in the item's stock UOM (orders made from
it use the stock UOM), but the row never said which unit that was.
Fetch the stock UOM onto the row and backfill existing rows.

* feat(manufacturing): close individual blanket order items

Use the row-level Close Items and Reopen Items actions from #57596 on
Blanket Order. A row can be closed while part of its qty is still
unordered. Closing every row closes the Blanket Order, and reopening a
row reopens it.

on_item_close_status_change becomes optional, like validate_item_close,
since a Blanket Order has no progress fields to recalculate.

* test(manufacturing): closing blanket order items

* feat(manufacturing): skip closed blanket order items when ordering

A closed row is left out when creating an order from the Blanket Order,
and out of the row picker and the item details lookup for that item.
Saving or submitting an order against it fails, and so does raising a
linked row's qty with Update Items.

* test(manufacturing): closed blanket order items are not ordered

* feat(manufacturing): show row state on blanket order items

Mark each Blanket Order row the way Purchase Order marks its rows: gray
when closed, green when fully ordered, orange while qty is still
pending.
2026-09-23 22:24:36 +05:30
Mihir Kandoi
df4a9f8d9f feat(manufacturing): close and re-open blanket orders (#59341)
* feat(manufacturing): close and re-open blanket orders

Blanket Order had no status field, so a finished or cancelled agreement
looked the same as an active one and could still be ordered against.

Add a status (Draft, Submitted, Closed, Cancelled) driven by the status
map, with Close and Re-open buttons under Status. A closed Blanket Order
hides its Create buttons. The patch backfills status from docstatus.

* test(manufacturing): blanket order status through close, re-open and cancel

* feat(manufacturing): stop ordering against a closed blanket order

Hiding the Create button is not enough: a Sales or Purchase Order can
still pick the Blanket Order on its rows. A closed Blanket Order is now
left out of the row picker and the item details lookup, and making,
saving or submitting an order against it fails. Update Items fails
when it raises the qty of a row linked to one.

* test(manufacturing): closed blanket order blocks new orders and qty increases

* perf(manufacturing): check closed blanket order on the loaded document

validate_against_blanket_order already loads each Blanket Order, so read
its status from there instead of querying it again per Blanket Order.
make_order and Update Items use the same method.

* fix(manufacturing): check every order row linked to a blanket order

A Sales or Purchase Order row could keep its Blanket Order link with
Against Blanket Order unticked, for example through the API or an
import. Validation skipped that row, so it could exceed the allowance or
order against a closed Blanket Order, yet its qty still counted as
ordered. Check every linked row, the same rows that ordered qty counts.

* test(manufacturing): linked order row is checked without against blanket order

* fix(manufacturing): lock the blanket order while an order is checked against it

Saving an order read the Blanket Order without a lock, so another
transaction could close it, or close one of its rows, between the check
and the commit. Load it FOR UPDATE, which locks the Blanket Order and
its rows until the order is saved; Close and Close Items wait for it.
Blanket Orders are locked in name order so two orders cannot deadlock.
The lock also serialises the existing allowance check. Update Items
takes the same lock.

* fix(manufacturing): check blanket order expiry in update items

Update Items checked only whether the Blanket Order was closed. Its To
Date can be edited after submit, so a submitted order dated after the
new To Date could still grow. Keep the order-level checks in one method,
validate_can_be_ordered, used by make_order, order validation and
Update Items.

* test(manufacturing): update items cannot raise qty after blanket order expires
2026-09-23 22:24:35 +05:30
Shllokkk
6367ff9f8d test: cover missing and item-group-fallback supplier in MRP make_order 2026-09-23 22:12:47 +05:30
Shllokkk
43913d5c2a fix: resolve MRP purchase supplier with item group fallback, else throw 2026-09-23 22:12:44 +05:30
Mihir Kandoi
328ec92662 feat: email templates for customer-facing emails (#59332) 2026-09-23 22:07:10 +05:30
Mihir Kandoi
a434defbb7 fix(manufacturing): stop ordering against an expired blanket order (#59346) 2026-09-23 21:15:47 +05:30
Mihir Kandoi
62b69d6dcf fix: translate doctype name values in report columns (#59342) 2026-09-23 21:14:44 +05:30
Mihir Kandoi
33d29274ba perf(stock): serve Item link search from an FTS5 trigram index (#59166) 2026-09-23 21:14:12 +05:30
Mihir Kandoi
5426a228c5 fix: keep against voucher link in general ledger for non-english users (#59345) 2026-09-23 15:11:03 +00:00
Mihir Kandoi
df428d6cc7 refactor: use frm instead of cur_frm in client scripts (#59315) 2026-09-23 19:33:03 +05:30
Pandiyan P
574f345e91 fix(selling): use preferred addresses in customer quotations (#59336) 2026-09-23 18:45:02 +05:30
Nishka Gosalia
9a79253ee1 Merge pull request #59329 from nishkagosalia/job-card-ux-cleanup
fix(UX): Job card cleanup
2026-09-23 18:17:12 +05:30
nishkagosalia
a05229b988 fix(UX): Job card cleanup 2026-09-23 16:45:40 +05:30
Mihir Kandoi
a0b7db19d9 fix(quality): block Quality Inspection submit without a sample size (#59330) 2026-09-23 10:59:37 +00:00
rohitwaghchaure
bc6e5e2060 fix: permission checks for serial / batch creation using scanning (#59325) 2026-09-23 15:57:00 +05:30
Diptanil Saha
5de2ac1f26 fix(pos): quote the reversed row's rate on a consolidated credit note (#59320) 2026-09-23 14:57:21 +05:30
Mihir Kandoi
a46930e10a fix(manufacturing): subtract consumption entries from available materials (#59310) 2026-09-23 13:37:26 +05:30
rohitwaghchaure
18093079d9 feat(stock): GL-only reposting from Stock and Account Value Comparison report (backport #59127) (#59307)
* feat(stock): GL-only reposting from Stock and Account Value Comparison report (backport #59127)

* fix: take GL repost posting date from the voucher's stock ledger and skip GL-only rows
2026-09-23 07:53:57 +00:00
rohitwaghchaure
f4979e6650 fix: production plan layout (#59185) 2026-09-23 13:21:07 +05:30
rohitwaghchaure
63413f8bfe perf: reduce memory consumption during reposting (backport #59117) (#59306) 2026-09-23 13:12:11 +05:30
Deepesh Garg
02c04ae167 Merge pull request #59292 from deepeshgarg007/erpnext_persona_skip
fix: Skip ERPNext persona for HRMS signups
2026-09-23 13:11:21 +05:30
Deepesh Garg
2dcf82a2ab Merge pull request #59276 from deepeshgarg007/bank-reco-voucher-registry
feat: make bank reconciliation dialog voucher types extensible
2026-09-23 12:48:37 +05:30
Deepesh Garg
ca1a88b53b Merge branch 'develop' into erpnext_persona_skip 2026-09-23 12:37:11 +05:30
Mihir Kandoi
d75b957ce0 feat(selling): put a customer on hold (#59303) 2026-09-23 12:13:48 +05:30
Khushi Rawat
e9480bd0e1 Merge pull request #59304 from khushi8112/fix/opening-depreciation-periods
fix: count complete opening depreciation periods accurately
2026-09-23 12:08:13 +05:30
rohitwaghchaure
8793ad8264 fix: negative stock value for moving average items with mixed batchwise valuation (backport #59102, #59111) (#59291)
* fix: negative stock value for moving average item with mixed batchwise valuation (#59099)

* fix: negative stock value for moving average item with mixed batchwise valuation

* chore: remove redundant docstring

* test: restore frappe flags in a finally block

(cherry picked from commit 262fdf3e69)

* perf: skip legacy batch ledger lookups when no legacy entry exists (backport #59110) (#59111)

* perf: skip legacy batch ledger lookups when no legacy entry exists (#59110)

* perf: skip legacy batch ledger lookups when no legacy entry exists

(cherry picked from commit 0130d287f6)

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
(cherry picked from commit 4af1b0db4e)

* perf: probe legacy batch ledgers on the batch_no index

The batch_no, item_code, warehouse index was dropped in v15, so the item and
warehouse probe fell back to the item ledger and read every row to look at
batch_no. Probe the batches the aggregates already filter on instead, which
the batch_no index covers.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-09-23 06:37:59 +00:00
khushi8112
3691ca5b5b test(assets): cover complete opening depreciation periods 2026-09-23 11:49:02 +05:30
khushi8112
56f24a6adf fix(assets): count complete opening depreciation periods accurately 2026-09-23 11:48:32 +05:30
Deepesh Garg
60f0a736ab test: cover the bank reconciliation voucher type registry
Node unit tests evaluate the dialog script with stubbed frappe globals and
cover preserving pre-registered types, filtering them per transaction,
injecting their dialog fields, the submit and full-page return contracts
and rejecting unregistered types. yarn test:js runs them and a js unit
tests job runs them on every PR.

Also restores the track_voucher call on the full-page path that the merge
with develop dropped, and registers the after-submit reconciliation hooks
for every registered voucher type.
2026-09-23 09:48:23 +05:30
Deepesh Garg
1ba071d657 Merge branch 'develop' into erpnext_persona_skip 2026-09-23 09:10:04 +05:30
Mihir Kandoi
b3d55db893 fix(stock): return material that a document rejected in full (#59280)
* fix(stock): return material that a document rejected in full

A receipt may leave the accepted quantity at zero when the whole row is
rejected, and so may an invoice that updates stock. Neither could be sent back:
the return mapper kept only rows with an accepted quantity, so it produced a
document with no rows at all, and the checks that a return carries something
looked at the accepted quantity alone.

Carry a row that has a rejected quantity, and count that quantity as material
going back.

* test(stock): cover the return of a receipt that rejected every unit

* fix(stock): send rejected material back at the rate it came in at

Material a transfer rejected in full went back to the in-transit warehouse with
no value at all, and the invoice, having no accepted quantity to price, fell
back to a single unit's rate.

Three things stood in the way of simply reversing the rate the material came in
at. A return of a transfer never read that rate. The value going back counted
the accepted quantity alone. And the rate was looked up against the accepted
warehouse, which had received nothing.

Ten units received at 100 into the rejected warehouse now go back as 1000, and
the entries say the same as the stock.

* test(accounts): cover the return of a transfer that rejected every unit
2026-09-22 20:32:13 +05:30
Mihir Kandoi
876bf0b7f7 fix(stock): move rejected material of an internal transfer on an invoice too (#59260)
* feat(stock): move rejected material of an internal transfer on an invoice too

An invoice that updates stock moves the same material as a receipt, so it was
left with the fault the layers below fixed for the receipt: the rejected
material stayed in the in-transit warehouse and was counted in the rejected
warehouse as well.

Its entries had no accounting for rejected material at all, which is why those
layers stopped at the receipt. The entry that books the rejected warehouse
serves both cases now: an internal transfer credits the in-transit warehouse for
the accepted and the rejected material together, and an invoice that bills the
rejected qty keeps its cost on the supplier entry, as before.

`is_internal_receipt` covers an invoice that updates stock, so the qty, the
packages, cancelling and returning behave as they do on a receipt.

* test(accounts): cover an internal transfer invoice that rejects material

* fix(stock): validate a rejected package against the rejected quantity

On an invoice row the quantity field was forced to `stock_qty` for every
package, so the package of rejected material was compared with the accepted
quantity and a partly rejected internal transfer could not be saved.

Keep the override for the accepted package only, and let an explicitly passed
field through untouched.

* test(accounts): cover rejected batch material on an internal transfer invoice

* fix(accounts): let a stock updating invoice reject every unit of a row

A receipt may leave the accepted quantity at zero when the whole row is
rejected. An invoice that updates stock moves the same material, but the row was
refused with "Quantity for Item cannot be zero".

Share the receipt test with `is_internal_receipt()` so both exemptions follow
one rule.

* test(accounts): cover a stock updating invoice that rejects a whole row

* fix(stock): count a rejected package in the units a package counts in

A package holds stock units, but the package of rejected material was compared
with the rejected quantity as the row states it. A row of one box of twelve was
refused for holding twelve units, and resizing such a package cut it to the
number of boxes.

Read the row the way the package was built, so a rejected quantity is carried
through the conversion factor like every other quantity.

* test(stock): cover rejected material of a transfer bought in another unit
2026-09-22 20:32:13 +05:30
Mihir Kandoi
16b1be814c fix(accounts): bill the rejected quantity on a stock updating invoice (#59258)
* feat(accounts): bill the rejected quantity on a stock updating invoice

Both rejected material settings are written for the receipt flow. The receipt
books rejected material against Stock Received But Not Billed, and the invoice
mapped from it carries the received qty with nothing rejected, so the supplier
pays for every unit received and that account clears.

An invoice that moves stock itself has no receipt to do that. It bills the
accepted qty alone, so the setting that asks for rejected material to be valued
had nothing to back the value it asked for, and the layer below zeroes it.

Such an invoice bills the received qty now when the setting is on, spreads the
valuation over the same qty, and debits the rejected warehouse from its own
entries, so the supplier entry carries the cost. An internal transfer bills
nothing of the sort: its material is paid for by the warehouse it came out of.

* test(accounts): cover the rejected quantity billed on a stock updating invoice

* fix(accounts): tell the form whether the rejected quantity is billed

The form read the two settings off the document, where they never appear: a
doctype settings map drives the settings panel of a form, it does not put those
fields on the document. The amount of a row stayed at the accepted qty until the
document was saved and the server worked it out again.

Both settings go into the boot now, and the form reads them from there. An
internal transfer is left alone, as it is on the server.

* test(accounts): give the project purchase cost test its own payable account

The test bills in USD but let the payable account be chosen for it, so it passed
only when an earlier test had left a payable account in that currency behind.

* test(accounts): read the invoices back before cancelling them

Submitting an invoice against a project writes to it again, so the copy the test
holds is already behind and cancelling it fails on the timestamp.

* test(accounts): restore the buying settings the tests change

Both tests put the settings back by hand, one of them to a value the site never
had, so every test that ran afterwards saw the rejected quantity billed. Let the
suite save and restore them.

* fix(accounts): spread a discount over the quantity the invoice bills

An invoice that bills the rejected quantity carries an amount for every unit
received, but the net rate was still divided by the accepted quantity alone. Six
accepted and four rejected at 100 with a tenth off gave a net rate of 150 and
wrote that to the item as its last purchase rate.

Divide by the quantity the amount was built from. Nothing changes for a document
that does not bill the rejected quantity.

* test(accounts): cover a discount on an invoice that bills the rejected quantity

* fix(accounts): book the rejected warehouse from the stock it received

The entry was written only while the setting that bills the rejected quantity
was on, so an invoice reposted after that setting changed lost the entry while
keeping the supplier credit that paid for the material, and reposting failed on
the difference. The stock the invoice moved is what the entry records, so read
that instead. Material that nothing paid for carries no value and still books
nothing.

A return that stands on its own compared what the supplier was credited with the
stock of the accepted warehouse alone, and booked the rejected material a second
time as a variance. Count both warehouses, since both come back.

* test(accounts): cover the rejected warehouse after a repost and on a return

* docs(buying): say where the rejected quantity is billed

An invoice that updates stock bills the rejected quantity too, with no receipt
in front of it.

* docs(buying): drop the list of documents from the billing description

The purchase cycle says it.
2026-09-22 20:32:12 +05:30
Mihir Kandoi
ecc643fde0 fix(stock): stop valuing rejected material on a stock updating invoice (#59257)
* fix(stock): stop valuing rejected material on a stock updating invoice

`set_valuation_rate_for_rejected_materials` is written for the receipt flow. A
receipt books rejected material against Stock Received But Not Billed, and the
invoice mapped from it carries the received qty with nothing rejected, so the
supplier pays for all of it and that account clears.

An invoice that moves stock itself has no receipt to do that. It bills the
accepted qty alone, while the setting still gave its rejected material the
invoice rate, so the rejected warehouse received stock value that nothing paid
for and no entry backed.

One predicate now answers whether rejected material carries value, for plain
rows and for rows tracked by a package alike. Material of an internal transfer
always does, since its value was credited out of the in-transit warehouse. A
receipt follows the setting. A stock updating invoice does not, until it bills
that material.

* test(accounts): cover rejected material value on a stock updating invoice

* fix(stock): read the transit test from where this layer keeps it

The layer below asks the package itself whether the material came from an
in-transit warehouse. Here that question is answered by the buying settings.

* docs(buying): say where rejected material is valued

The setting reaches every document of the purchase cycle that receives material,
not the receipt alone.
2026-09-22 20:32:12 +05:30
Mihir Kandoi
df3f952fac fix(stock): empty the in-transit warehouse when every unit is rejected (#59256)
* fix(stock): deduct rejected qty from the in-transit warehouse

On an internal transfer the receipt took only the accepted qty out of the
in-transit warehouse, while the rejected qty was booked into the rejected
warehouse, so the rejected material was counted in both.

It also carried stock value, because an internal transfer anchors every inward
entry to the rate of the delivery note, but the rejected warehouse got no
accounting entry unless Buying Settings asked for one. The stock value and the
account value then disagreed.

The entry for the in-transit warehouse covers the accepted and the rejected qty
now, and the rejected warehouse is booked whatever that setting says, since the
value came out of the in-transit warehouse either way.

* test(stock): cover rejected qty on an internal transfer receipt

* fix(stock): let rejected serial and batch material leave the in-transit warehouse

A serial or batch item rejected on an internal transfer could not be received at
all. The package for the in-transit warehouse is copied from the delivery note,
and the copy was never resized, because the check compared a positive qty against
the negative total of an outgoing package.

The package of a row follows the split now. A row that rejects material carries
the package of its accepted warehouse, holding the accepted material alone,
which is the entry it belongs to and the total the desk sets its accepted qty
from. A row that rejects nothing keeps the package of the in-transit warehouse it
came out of. Editing the split moves the package from one to the other, and a row
that accepts nothing carries no package at all.

The entry for the in-transit warehouse gets a package of its own, holding the
accepted and the rejected material together. A landed cost voucher or a repost
reuses it rather than building a second one, which would make the batch qty count
the material twice.

Rejected material of an internal transfer keeps its rate, since its value was
credited out of the in-transit warehouse; refusing it a rate left the difference
to be written off. Cancelling reverses that warehouse with the package its entry
posted, after the rejected warehouse, so serial numbers are not put back and
taken out again. A return builds an inward package covering both.

The resize also fixes an ordinary partial receipt of a tracked item bought in
another UOM: the package is sized in stock UOM, which is what the row is
validated against.

* test(stock): cover rejected serial and batch material on an internal transfer

* fix(stock): build the package of rejected material on an internal transfer

A receipt of an internal transfer builds one package and stops there, so a
tracked row that rejects material had nothing to say where that material came
from. The desk offers no field for it either, and the receipt could not be
submitted: the entry for the in-transit warehouse was handed the package of the
accepted warehouse.

The row takes a package of its own for the rejected material now, built from what
the delivery note put in the in-transit warehouse. Moving the package of a row
between the two warehouses also reads that delivery note package, instead of the
package it happens to hold, which no longer covers the qty once the split changes.

* test(stock): cover the package built for rejected batch material

* fix(stock): empty the in-transit warehouse when every unit is rejected

A receipt that rejects the whole qty left the material in the in-transit
warehouse and added it to the rejected warehouse as well, because the entries of
a row were made only when there was an accepted qty. They are made from the qty
that leaves the source warehouse now, so a row with no accepted qty is posted
like any other. One gate replaces two nested ones, which moves the body of the
loop out by a level; read the diff with whitespace ignored.

Such a row also carried no valuation rate, since the rate of an internal transfer
is taken from the accepted qty alone, and the rejected warehouse was then debited
without a matching credit. The rate falls back to the rejected qty.

Returning that material from the rejected warehouse left the in-transit warehouse
holding the qty at no value and wrote the value off: the return has no delivery
note reference, so its entry for that warehouse got no rate, and the entry
against it was suppressed because rejected material normally carries none. It
takes the rate of the return now, and the value of the source warehouse is signed
rather than absolute, so a return debits the warehouse the material returns to.

* test(stock): cover an internal transfer with every unit rejected

* fix(stock): keep resizing the package of a row whose qty changed

The package of rejected material was built between the two branches that build
and resize the package of a row, which left the resize attached to it. A row
whose qty changed after its package was built stopped being resized, and the
receipt was refused for the qty it no longer had.

* test(stock): state the rejected valuation setting the transfer test relies on

* fix(stock): keep a charge off the rejected material of a transfer

A landed cost voucher rebuilds the receipt with the charge spread over the
material it accepted, and the package of rejected material was then valued at
that same rate. Three units rejected out of a transfer worth 100 each came to
351.43 after a charge of 120, and the difference was credited to Cost of Goods
Sold to make the entries balance.

Rejected material of a transfer keeps the value it arrived in transit with. The
share of the charge that would have sat on it is expensed instead.

* test(stock): cover a charge on a transfer that rejected material
2026-09-22 20:32:12 +05:30
Mihir Kandoi
726c92f8ae fix(stock): let rejected serial and batch material leave the in-transit warehouse (#59252)
* fix(stock): let rejected serial and batch material leave the in-transit warehouse

A serial or batch item rejected on an internal transfer could not be received at
all. The package for the in-transit warehouse is copied from the delivery note,
and the copy was never resized, because the check compared a positive qty against
the negative total of an outgoing package.

The package of a row follows the split now. A row that rejects material carries
the package of its accepted warehouse, holding the accepted material alone,
which is the entry it belongs to and the total the desk sets its accepted qty
from. A row that rejects nothing keeps the package of the in-transit warehouse it
came out of. Editing the split moves the package from one to the other, and a row
that accepts nothing carries no package at all.

The entry for the in-transit warehouse gets a package of its own, holding the
accepted and the rejected material together. A landed cost voucher or a repost
reuses it rather than building a second one, which would make the batch qty count
the material twice.

Rejected material of an internal transfer keeps its rate, since its value was
credited out of the in-transit warehouse; refusing it a rate left the difference
to be written off. Cancelling reverses that warehouse with the package its entry
posted, after the rejected warehouse, so serial numbers are not put back and
taken out again. A return builds an inward package covering both.

The resize also fixes an ordinary partial receipt of a tracked item bought in
another UOM: the package is sized in stock UOM, which is what the row is
validated against.

* test(stock): cover rejected serial and batch material on an internal transfer

* fix(stock): build the package of rejected material on an internal transfer

A receipt of an internal transfer builds one package and stops there, so a
tracked row that rejects material had nothing to say where that material came
from. The desk offers no field for it either, and the receipt could not be
submitted: the entry for the in-transit warehouse was handed the package of the
accepted warehouse.

The row takes a package of its own for the rejected material now, built from what
the delivery note put in the in-transit warehouse. Moving the package of a row
between the two warehouses also reads that delivery note package, instead of the
package it happens to hold, which no longer covers the qty once the split changes.

* test(stock): cover the package built for rejected batch material

* fix(stock): keep resizing the package of a row whose qty changed

The package of rejected material was built between the two branches that build
and resize the package of a row, which left the resize attached to it. A row
whose qty changed after its package was built stopped being resized, and the
receipt was refused for the qty it no longer had.

* fix(stock): keep a charge off the rejected material of a transfer

A landed cost voucher rebuilds the receipt with the charge spread over the
material it accepted, and the package of rejected material was then valued at
that same rate. Three units rejected out of a transfer worth 100 each came to
351.43 after a charge of 120, and the difference was credited to Cost of Goods
Sold to make the entries balance.

Rejected material of a transfer keeps the value it arrived in transit with. The
share of the charge that would have sat on it is expensed instead.

* test(stock): cover a charge on a transfer that rejected material
2026-09-22 20:32:11 +05:30
Mihir Kandoi
c0f13b01de fix(stock): deduct rejected qty from the in-transit warehouse (#59251)
* fix(stock): deduct rejected qty from the in-transit warehouse

On an internal transfer the receipt took only the accepted qty out of the
in-transit warehouse, while the rejected qty was booked into the rejected
warehouse, so the rejected material was counted in both.

It also carried stock value, because an internal transfer anchors every inward
entry to the rate of the delivery note, but the rejected warehouse got no
accounting entry unless Buying Settings asked for one. The stock value and the
account value then disagreed.

The entry for the in-transit warehouse covers the accepted and the rejected qty
now, and the rejected warehouse is booked whatever that setting says, since the
value came out of the in-transit warehouse either way.

* test(stock): cover rejected qty on an internal transfer receipt

* test(stock): state the rejected valuation setting the transfer test relies on
2026-09-22 20:32:11 +05:30
rohitwaghchaure
23afc786bb feat: use serial no wise valuation switch on item (backport #59082) (#59182)
feat: use serial no wise valuation switch on item (#59082)
2026-09-22 14:00:42 +00:00
Mihir Kandoi
3bf3bc709f fix(stock): carry the batch of a delivery into the in-transit warehouse (#59264) 2026-09-22 18:17:14 +05:30
Pandiyan P
102842253b fix(stock): use net purchase values in landed cost vouchers (#59274)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:05:16 +05:30
Sudharsanan Ashok
e814d13126 fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:04:26 +05:30
Sudharsanan Ashok
7f608f31eb fix(stock): keep mapped cost center in stock entry items (#59286) 2026-09-22 18:02:53 +05:30
Sudharsanan Ashok
f990df9deb refactor(selling): remove ensure delivery based on produced serial no (#59217)
* refactor(selling): remove ensure delivery based on produced serial no

the ensure_delivery_based_on_produced_serial_no checkbox on sales order
item only ever validated itself at sales order save: serialized item,
active bom, and the same setting on every row of an item. nothing
downstream read the flag, so a delivery note or stock updating sales
invoice could ship any serial no and submit silently. the field promised
a guarantee it never enforced.

remove the field, its typing entry and validate_serial_no_based_delivery.
stock reservation on the sales order and work order is the supported way
to hold produced stock for an order. the database column is left in place
by migrate, so no data is dropped.

* chore(stock): remove dead reserved serial and batch no validators

validate_reserved_serial_nos and validate_reserved_batch_nos lost their
only callers in a20951e1cd ("fix: reserved serial nos validation"), which
moved serial level reservation checks into serial and batch bundle but
left both functions in stock ledger. drop them along with the three
imports that only they used.
2026-09-22 17:54:54 +05:30
Sudharsanan Ashok
62cb38d36b fix(stock): seed bin values when cancelling a stock voucher (#59250)
* fix(stock): seed bin values when cancelling a stock voucher

cancellation flags every sle of the voucher before update_entries_after
runs, so get_sle_against_current_voucher returns nothing and the seeding
added in #57380 never fires. prev_sle_dict stays empty, update_bin()
writes nothing, and the bin keeps the stock value and valuation rate it
had before the cancellation while its quantity is restored.

seed from the args when the query comes back empty, leaving the existing
anchor in place whenever a live entry shares the posting datetime.

* test(stock): cover bin stock value after cancelling a transfer

a transfer between two warehouses that both hold stock, then cancelled:
both bins must return to their previous quantity, valuation rate and
stock value. fails on develop with 500.0 != 1000.
2026-09-22 17:53:07 +05:30
Deepesh Garg
eca99d3df4 fix: Skip ERPNext persona for HRMS signups 2026-09-22 17:42:33 +05:30
Mihir Kandoi
8d6ee86ba3 fix: don't bump Customer timestamp when caching primary address (#59285)
Saving a Customer runs create_primary_address, which calls frappe.set_value on the
linked Address to ensure is_primary_address. That is a full document save, so
ERPNextAddress.on_update fires and writes the address display back to
Customer.primary_address with update_modified=True. The Customer row's modified
moves after the document has already been written, so the form keeps the older
timestamp and the next save from the same form fails check_if_latest with
TimestampMismatchError.

primary_address is a denormalized display cache, not a user edit, so writing it
must not move the optimistic lock timestamp.
2026-09-22 12:10:21 +00:00
Khushi Rawat
e3fc1790d4 Merge pull request #59262 from aerele/fix-reverse-je-mandatory-fields
fix(journal-entry): carry reference fields into reverse journal entry
2026-09-22 17:15:30 +05:30
Deepesh Garg
db6dad7ddd Merge branch 'develop' of https://github.com/frappe/erpnext into bank-reco-voucher-registry 2026-09-22 16:49:40 +05:30
rohitwaghchaure
719b53f2ad fix: do not zero out backdated stock at a stock reco adjustment entry (#59269)
* fix: do not zero out backdated stock at a stock reco adjustment entry

* fix: keep a stock reco adjustment entry value-only on cancel and refresh

* fix: read stock reco adjustment rows once and value them from the ledger
2026-09-22 16:38:51 +05:30
Pandiyan P
79fd06a1ba fix(crm): keep opportunity type default untranslated (#59277) 2026-09-22 11:06:45 +00:00
Khushi Rawat
5ebee655b7 Merge pull request #59180 from khushi8112/pos-price-list-exchange-rate
fix: refresh price list exchange rate when currency changes
2026-09-22 15:29:36 +05:30
Deepesh Garg
96db1071da feat: make bank reconciliation dialog voucher types extensible
Other apps can register additional "Create Voucher" document types in
erpnext.accounts.bank_reconciliation.voucher_types with their own dialog
fields, applicability check and creation call. Payment Entry and Journal
Entry now go through the same registry, which also removes the duplicated
create and edit-in-full-page call blocks.
2026-09-22 15:29:18 +05:30
Mihir Kandoi
9f2b05c103 fix: skip serial nos with no stock when reconciling specific serial nos (#59266) 2026-09-22 14:31:14 +05:30
ervishnucs
9e01992217 fix(journal-entry): carry reference fields into reverse journal entry 2026-09-22 13:25:11 +05:30
Shllokkk
5ebc52c95c Merge pull request #59240 from Shllokkk/asset-depr-schedule-company-filter
fix: asset field unfilterable on manual Asset Depreciation Schedule
2026-09-22 12:36:50 +05:30
ruthra kumar
86eb9c6204 Merge pull request #59230 from ruthra-kumar/more_changes_to_pcv_using_mapreduce
refactor(pcv): status on list view, progress update on UI
2026-09-22 12:19:05 +05:30
Afsal Syed
893fc75df4 fix(stock): apply transit warehouse filter to target warehouse in items table (#59192) 2026-09-22 06:48:07 +00:00
ruthra kumar
f2279a139f refactor(pcv): status on list view, progress update on UI 2026-09-22 11:50:28 +05:30
Mihir Kandoi
30e0382aa9 feat(selling): group Sales Order Analysis by item (#59236) 2026-09-22 11:09:20 +05:30
Mihir Kandoi
d2b1965cc8 fix(stock): compare transit quantities in stock UOM (#59202) 2026-09-22 11:08:09 +05:30
Shllokkk
a931c7d2f4 fix: remove company link filter on asset field in Asset Depreciation Schedule 2026-09-22 03:36:40 +05:30
khushi8112
221baabf9e test: cover price list exchange rate currency changes 2026-09-18 17:47:34 +05:30
khushi8112
8b59d66f94 fix: refresh price list exchange rate when currency changes 2026-09-18 17:34:55 +05:30
210 changed files with 8882 additions and 1585 deletions

View File

@@ -23,6 +23,19 @@ jobs:
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1
js-unit-tests:
name: js unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Run JS unit tests
run: yarn test:js
semgrep:
name: semgrep
runs-on: ubuntu-latest

View File

@@ -47,10 +47,13 @@ class ERPNextAddress(Address):
super().on_update()
address_display = get_address_display(self.as_dict())
filters = {"customer_primary_address": self.name}
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
for customer_name in customers:
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
customers = frappe.db.get_all(
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
)
for customer in customers:
frappe.db.set_value(
"Customer", customer, "primary_address", address_display, update_modified=False
)
@frappe.whitelist()

View File

@@ -12,7 +12,7 @@ frappe.ui.form.on("Accounting Period", {
doc: frm.doc,
callback: function (r) {
if (r.message) {
cur_frm.clear_table("closed_documents");
frm.clear_table("closed_documents");
r.message.forEach(function (element) {
var c = frm.add_child("closed_documents");
c.document_type = element.document_type;

View File

@@ -1,14 +1,14 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
cur_frm.add_fetch("bank_account", "account", "account");
cur_frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
cur_frm.add_fetch("bank_account", "iban", "iban");
cur_frm.add_fetch("bank_account", "branch_code", "branch_code");
cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.add_fetch("bank_account", "account", "account");
frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
frm.add_fetch("bank_account", "iban", "iban");
frm.add_fetch("bank_account", "branch_code", "branch_code");
frm.add_fetch("bank", "swift_number", "swift_number");
frm.set_query("reference_doctype", function () {
return {
filters: {
@@ -63,11 +63,15 @@ frappe.ui.form.on("Bank Guarantee", {
},
start_date: function (frm) {
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
cur_frm.set_value("end_date", end_date);
frm.events.set_end_date(frm);
},
validity: function (frm) {
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
cur_frm.set_value("end_date", end_date);
frm.events.set_end_date(frm);
},
set_end_date: function (frm) {
let end_date = frappe.datetime.add_days(frm.doc.start_date, frm.doc.validity - 1);
frm.set_value("end_date", end_date);
},
});

View File

@@ -32,7 +32,7 @@ frappe.ui.form.on("Journal Entry", {
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
erpnext.journal_entry.add_custom_buttons(frm);
erpnext.journal_entry.toggle_fields_based_on_currency(frm);
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);

View File

@@ -253,6 +253,10 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
def post_process(source, target) -> None:
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -2,8 +2,6 @@
// For license information, please see license.txt
frappe.provide("erpnext.accounts.dimensions");
cur_frm.cscript.tax_table = "Advance Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Payment Entry");
erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
@@ -46,6 +44,8 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.cscript.tax_table = "Advance Taxes and Charges";
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);

View File

@@ -431,4 +431,4 @@ frappe.ui.form.on("Payment Reconciliation Allocation", {
},
});
extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({ frm: cur_frm }));
frappe.ui.form.set_controller("Payment Reconciliation", erpnext.accounts.PaymentReconciliationController);

View File

@@ -486,6 +486,13 @@ 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"))
account_currency = frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
currency=account_currency,
)
entries = []
for pay in args.get("payments"):
@@ -493,11 +500,17 @@ class PaymentReconciliation(Document):
for inv in args.get("invoices"):
if pay.get("amount") >= inv.get("outstanding_amount"):
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
pay["amount"] = flt(
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
allocated_amount_precision,
)
inv["outstanding_amount"] = 0
else:
res = self.get_allocated_entry(pay, inv, pay["amount"])
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
inv["outstanding_amount"] = flt(
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
allocated_amount_precision,
)
pay["amount"] = 0
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))

View File

@@ -1659,6 +1659,77 @@ class TestPaymentReconciliation(ERPNextTestSuite):
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
pr.reconcile()
@ERPNextTestSuite.change_settings("System Settings", {"currency_precision": 2})
def test_allocate_entries_rounds_running_balance_to_currency_precision(self):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": "INR",
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": "INR",
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 2))
@ERPNextTestSuite.change_settings(
"System Settings", {"currency_precision": "", "use_number_format_from_currency": 1}
)
def test_allocate_entries_rounds_running_balance_to_account_currency_precision(self):
account_currency = frappe.get_cached_value("Account", self.debit_to, "account_currency")
original_number_format = frappe.db.get_value("Currency", account_currency, "number_format")
frappe.db.set_value("Currency", account_currency, "number_format", "#,###.###")
self.addCleanup(
frappe.db.set_value, "Currency", account_currency, "number_format", original_number_format
)
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": account_currency,
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": account_currency,
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 3))
def test_reverse_payment_against_payment_for_supplier(self):
"""
Reconcile a payment against a reverse payment, for a supplier.

View File

@@ -1,9 +1,7 @@
cur_frm.add_fetch("payment_gateway_account", "payment_account", "payment_account");
cur_frm.add_fetch("payment_gateway_account", "payment_gateway", "payment_gateway");
cur_frm.add_fetch("payment_gateway_account", "message", "message");
frappe.ui.form.on("Payment Request", {
setup: function (frm) {
frm.add_fetch("payment_gateway_account", "message", "message");
frm.set_query("party_type", function () {
return {
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",

View File

@@ -280,12 +280,13 @@ class PeriodClosingVoucher(AccountsController):
data = self.get_data_for_mapreduce()
mapreduce(
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.process_date_range",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.aggregate_partial_result",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
data,
self.doctype,
self.name,
f"Closing FY {self.fiscal_year}",
)
def on_cancel(self):
@@ -330,8 +331,8 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self):
if frappe.db.estimate_count("GL Entry") > 100_000:
frappe.enqueue(
process_gl_and_closing_entries,
frappe.enqueue_task(
method=process_gl_and_closing_entries,
doc=self,
timeout=1800,
)
@@ -834,7 +835,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
return prev_closed_period_end_date
def mapper(val):
def process_date_range(val):
start_date = val.from_date
end_date = val.to_date
pcv = val.pcv
@@ -881,7 +882,7 @@ def mapper(val):
return res
def reducer(final, partial_res):
def aggregate_partial_result(final, partial_res):
if final is None:
final = []

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Period Closing Voucher"] = {
add_fields: ["gle_processing_status"],
get_indicator: function (doc) {
const status_colors = {
Draft: "red",
Submitted: "blue",
Cancelled: "red",
};
const gle_processing_status = {
"In Progress": [__("Processing GL Entries"), "blue"],
Completed: [__("Period Closed"), "green"],
Failed: [__("Period Closing Failed"), "red"],
};
if (doc.docstatus != 0) {
return [
gle_processing_status[doc.gle_processing_status][0],
gle_processing_status[doc.gle_processing_status][1],
"gle_processing_status,=," + doc.gle_processing_status,
];
}
return [__(doc.docstatus), status_colors[doc.docstatus], "docstatus,=," + doc.docstatus];
},
};

View File

@@ -45,7 +45,7 @@ frappe.ui.form.on("POS Closing Entry", {
$("#jump_to_error").on("click", (e) => {
e.preventDefault();
frappe.utils.scroll_to(cur_frm.get_field("error_message").$wrapper, true, 30);
frappe.utils.scroll_to(frm.get_field("error_message").$wrapper, true, 30);
});
frm.add_custom_button(__("Retry"), function () {

View File

@@ -363,7 +363,7 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
}
};
extend_cscript(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm }));
frappe.ui.form.set_controller("POS Invoice", erpnext.selling.POSInvoiceController);
frappe.ui.form.on("POS Invoice", {
redeem_loyalty_points: function (frm) {

View File

@@ -9,6 +9,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder import DocType
from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
from frappe.utils.background_jobs import enqueue, is_job_enqueued
from frappe.utils.scheduler import is_scheduler_inactive
@@ -16,7 +17,6 @@ from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
class POSInvoiceMergeLog(Document):
@@ -214,6 +214,8 @@ class POSInvoiceMergeLog(Document):
loyalty_amount_sum, loyalty_points_sum, idx = 0, 0, 1
reversed_rows = get_reversed_rows([doc.return_against for doc in data if doc.is_return])
for doc in data:
old_new_item_map = frappe._dict()
old_new_tax_map = frappe._dict()
@@ -238,9 +240,13 @@ class POSInvoiceMergeLog(Document):
si_item.pos_invoice = doc.name
si_item.pos_invoice_item = item.name
if doc.is_return:
si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice(
doc.return_against, item.pos_invoice_item
)
reversed_row = reversed_rows.get(item.pos_invoice_item) or frappe._dict()
si_item.sales_invoice_item = reversed_row.get("name")
# quote the rate of the row being reversed: rounding an invoice-level discount
# can leave a return's net rate a minor unit above the sale's, and
# validate_returned_items refuses a return priced above its original
if si_item.sales_invoice_item:
si_item.rate = reversed_row.rate
if item.serial_and_batch_bundle:
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
items.append(si_item)
@@ -432,6 +438,28 @@ class POSInvoiceMergeLog(Document):
si.cancel()
def get_reversed_rows(return_against):
"""Rows of the consolidated sales these returns reverse, keyed by the POS invoice row."""
if not return_against:
return {}
sales_invoice = DocType("Sales Invoice")
sales_invoice_item = DocType("Sales Invoice Item")
rows = (
frappe.qb.from_(sales_invoice)
.from_(sales_invoice_item)
.select(sales_invoice_item.name, sales_invoice_item.rate, sales_invoice_item.pos_invoice_item)
.where(
(sales_invoice.name == sales_invoice_item.parent)
& (sales_invoice.is_return == 0)
& (sales_invoice_item.pos_invoice.isin(return_against))
)
).run(as_dict=True)
return {row.pos_invoice_item: row for row in rows}
def get_all_unconsolidated_invoices():
filters = {
"consolidated_invoice": ["in", ["", None]],

View File

@@ -1,7 +1,10 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from contextlib import contextmanager
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
@@ -20,6 +23,67 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
@contextmanager
def rounding_method(method):
"""System Settings is also cached on frappe.local, so that copy has to go as well."""
previous = frappe.db.get_single_value("System Settings", "rounding_method")
try:
frappe.db.set_single_value("System Settings", "rounding_method", method)
frappe.local.system_settings = None
yield
finally:
frappe.db.set_single_value("System Settings", "rounding_method", previous)
frappe.local.system_settings = None
def sell_over_the_counter(lines, discount_percentage=0):
item_code, qty, rate = lines[0]
sale = create_pos_invoice(item_code=item_code, qty=qty, rate=rate, do_not_save=True)
for item_code, qty, rate in lines[1:]:
sale.append(
"items",
{
"item_code": item_code,
"qty": qty,
"rate": rate,
"price_list_rate": rate,
"warehouse": "_Test Warehouse - _TC",
"income_account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
if discount_percentage:
sale.apply_discount_on = "Net Total"
sale.additional_discount_percentage = discount_percentage
sale.run_method("calculate_taxes_and_totals")
payable = sale.rounded_total or sale.grand_total
sale.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": payable})
sale.paid_amount = sale.base_paid_amount = payable
sale.insert()
sale.submit()
return sale
def refund_over_the_counter(sale, qty=None):
"""Hand back every line of `sale`, `qty` of each when fewer units come back."""
note = make_sales_return(sale.name)
if qty is not None:
for item in note.items:
item.qty = qty
note.run_method("calculate_taxes_and_totals")
refundable = note.rounded_total or note.grand_total
note.payments[0].amount = refundable
for spare in note.payments[1:]:
spare.amount = 0
note.paid_amount = note.base_paid_amount = refundable
note.insert()
note.submit()
return note
class TestPOSInvoiceMergeLog(ERPNextTestSuite):
def setUp(self):
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
@@ -478,3 +542,81 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
"POS Invoice Merge Log", {"pos_closing_entry": closing_entry.name}, "company"
)
self.assertEqual(pos_merge_log_company, closing_entry.company)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_consolidating_returns_priced_off_a_rounded_invoice_discount(self):
"""A return works out its own share of an invoice-level discount, so rounding can leave
it a minor unit above the sale's, and validate_returned_items then refuses it.
Every shape that reaches a consolidated credit note goes through one closing entry:
a split landing on a half minor unit, the same item on two rows so the rows can only
be paired through sales_invoice_item, fewer units coming back than went out, and — as
a control — a sale with no invoice-level discount to split at all.
"""
for item_code in ("_Test Item", "_Test Item 2"):
make_stock_entry(to_warehouse="_Test Warehouse - _TC", item_code=item_code, rate=100, qty=40)
with rounding_method("Banker's Rounding (legacy)"):
tied = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)], discount_percentage=25
)
repeated = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item", 1, 57.14)], discount_percentage=25
)
oversold = sell_over_the_counter(
[("_Test Item", 3, 42.86), ("_Test Item 2", 3, 57.14)], discount_percentage=25
)
undiscounted = sell_over_the_counter([("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)])
# the sale and the return really do round the split apart
self.assertEqual(
{item.item_code: item.net_rate for item in tied.items},
{"_Test Item": 32.15, "_Test Item 2": 42.85},
)
returns = [
refund_over_the_counter(tied),
refund_over_the_counter(repeated),
refund_over_the_counter(oversold, qty=-1),
refund_over_the_counter(undiscounted),
]
self.assertEqual(
{item.item_code: item.net_rate for item in returns[0].items},
{"_Test Item": 32.14, "_Test Item 2": 42.86},
)
self.make_closing_entry()
for pos_invoice in [tied, repeated, oversold, undiscounted, *returns]:
pos_invoice.load_from_db()
self.assertTrue(
frappe.db.exists("Sales Invoice", pos_invoice.consolidated_invoice),
f"{pos_invoice.name} was not consolidated",
)
self.assertEqual(
frappe.db.get_value("Sales Invoice", pos_invoice.consolidated_invoice, "outstanding_amount"),
0,
)
for note in returns:
# no returned row may be priced above the row it reverses
for row in frappe.get_all(
"Sales Invoice Item",
filters={"parent": note.consolidated_invoice},
fields=["item_code", "rate", "sales_invoice_item"],
):
self.assertTrue(row.sales_invoice_item, f"{row.item_code} lost its link to the sale")
sold_rate = frappe.db.get_value("Sales Invoice Item", row.sales_invoice_item, "rate")
self.assertLessEqual(row.rate, sold_rate)
# returns for one customer land on a single credit note, which still adds up to
# everything handed back over the counter
refunded = {}
for note in returns:
refunded[note.consolidated_invoice] = refunded.get(note.consolidated_invoice, 0) + flt(
note.grand_total
)
for consolidated_name, handed_back in refunded.items():
self.assertEqual(
flt(frappe.db.get_value("Sales Invoice", consolidated_name, "grand_total"), 2),
flt(handed_back, 2),
)

View File

@@ -75,6 +75,7 @@
"more_info_tab",
"section_break_16",
"print_format",
"receipt_email_template",
"letter_head",
"column_break0",
"tc_name",
@@ -316,6 +317,13 @@
"label": "Print Format",
"options": "Print Format"
},
{
"description": "Subject and message for receipts emailed from the POS. The template gets the invoice as doc. A message typed by the cashier replaces the template message. Leave empty to send the default email.",
"fieldname": "receipt_email_template",
"fieldtype": "Link",
"label": "Receipt Email Template",
"options": "Email Template"
},
{
"depends_on": "update_stock",
"fieldname": "warehouse",
@@ -583,7 +591,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2026-08-21 23:11:45.419667",
"modified": "2026-09-23 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@@ -58,6 +58,7 @@ class POSProfile(Document):
print_format: DF.Link | None
print_receipt_on_order_complete: DF.Check
project: DF.Link | None
receipt_email_template: DF.Link | None
select_print_heading: DF.Link | None
selling_price_list: DF.Link | None
set_grand_total_to_default_mop: DF.Check

View File

@@ -3,8 +3,6 @@
frappe.provide("erpnext.accounts");
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
erpnext.accounts.payment_triggers.setup("Purchase Invoice");
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
erpnext.accounts.taxes.setup_tax_validations("Purchase Invoice");
@@ -478,7 +476,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
};
cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
frappe.ui.form.set_controller("Purchase Invoice", erpnext.accounts.PurchaseInvoice);
// Hide Fields
// ------------
@@ -504,70 +502,23 @@ function hide_fields(frm) {
frm.refresh_fields();
}
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
return {
filters: [
["Account", "account_type", "in", ["Cash", "Bank"]],
["Account", "is_group", "=", 0],
["Account", "company", "=", doc.company],
["Account", "report_type", "=", "Balance Sheet"],
],
};
};
cur_frm.fields_dict["items"].grid.get_field("item_code").get_query = function (doc, cdt, cdn) {
return {
query: "erpnext.controllers.queries.item_query",
filters: { is_purchase_item: 1 },
};
};
cur_frm.fields_dict["credit_to"].get_query = function (doc) {
// filter on Account
return {
filters: {
account_type: "Payable",
is_group: 0,
company: doc.company,
},
};
};
// Get Print Heading
cur_frm.fields_dict["select_print_heading"].get_query = function (doc, cdt, cdn) {
return {
filters: [["Print Heading", "docstatus", "!=", 2]],
};
};
cur_frm.set_query("wip_composite_asset", "items", function () {
return {
filters: { asset_type: "Composite Asset", docstatus: 0 },
};
});
cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.idx == 1 && d.expense_account) {
var cl = doc.items || [];
for (var i = 0; i < cl.length; i++) {
if (!cl[i].expense_account) cl[i].expense_account = d.expense_account;
frappe.ui.form.on("Purchase Invoice Item", {
expense_account: function (frm, cdt, cdn) {
let d = locals[cdt][cdn];
if (d.idx == 1 && d.expense_account) {
for (const item of frm.doc.items || []) {
if (!item.expense_account) item.expense_account = d.expense_account;
}
}
}
refresh_field("items");
};
cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function (doc) {
return {
filters: {
company: doc.company,
is_group: 0,
},
};
};
frm.refresh_field("items");
},
});
frappe.ui.form.on("Purchase Invoice", {
setup: function (frm) {
frm.cscript.tax_table = "Purchase Taxes and Charges";
frm.events.set_queries(frm);
frm.custom_make_buttons = {
"Purchase Invoice": "Return / Debit Note",
"Payment Entry": "Payment",
@@ -627,6 +578,50 @@ frappe.ui.form.on("Purchase Invoice", {
};
},
set_queries: function (frm) {
frm.set_query("cash_bank_account", function (doc) {
return {
filters: [
["Account", "account_type", "in", ["Cash", "Bank"]],
["Account", "is_group", "=", 0],
["Account", "company", "=", doc.company],
["Account", "report_type", "=", "Balance Sheet"],
],
};
});
frm.set_query("credit_to", function (doc) {
return {
filters: {
account_type: "Payable",
is_group: 0,
company: doc.company,
},
};
});
frm.set_query("select_print_heading", function () {
return {
filters: [["Print Heading", "docstatus", "!=", 2]],
};
});
frm.set_query("wip_composite_asset", "items", function () {
return {
filters: { asset_type: "Composite Asset", docstatus: 0 },
};
});
frm.set_query("cost_center", "items", function (doc) {
return {
filters: {
company: doc.company,
is_group: 0,
},
};
});
},
refresh: function (frm) {
frm.events.add_custom_buttons(frm);
},

View File

@@ -216,6 +216,11 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if doc.is_internal_supplier and item.valuation_rate:
credit_amount = flt(item.valuation_rate * item.stock_qty)
rejected_amount = self.make_rejected_warehouse_gl_entry(
gl_entries, item, voucher_wise_stock_value, inventory_account_map
)
credit_amount += rejected_amount
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
gl_entries.append(
self.get_gl_dict(
@@ -251,6 +256,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
)
else:
self.make_rejected_warehouse_gl_entry(
gl_entries, item, voucher_wise_stock_value, inventory_account_map
)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
@@ -564,6 +573,49 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
return stock_asset_rbnb or item.expense_account
def make_rejected_warehouse_gl_entry(
self, gl_entries, item, voucher_wise_stock_value, inventory_account_map
) -> float:
"""Book the material the invoice moved into the rejected warehouse.
An internal transfer carries the value credited out of the in-transit warehouse along with
the accepted material, so the entry against it is that warehouse, and the caller credits it
for both. On an ordinary invoice the supplier entry already holds the cost.
"""
doc = self.doc
if not (item.rejected_warehouse and flt(item.rejected_qty)):
return 0.0
transfers_rejected_material = doc.is_internal_transfer()
rejected_amount = flt(
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)),
item.precision("base_net_amount"),
)
if not rejected_amount:
return 0.0
rejected_account = doc.get_inventory_account_dict(item, inventory_account_map, "rejected_warehouse")
gl_entries.append(
self.get_gl_dict(
{
"account": rejected_account["account"],
"against": item.expense_account if transfers_rejected_material else doc.supplier,
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": rejected_amount,
"debit_in_transaction_currency": flt(
rejected_amount / doc.conversion_rate, item.precision("net_amount")
),
},
rejected_account["account_currency"],
item=item,
)
)
return rejected_amount if transfers_rejected_material else 0.0
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")
@@ -577,16 +629,24 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
net_rate = item.base_net_amount
if item.sales_incoming_rate:
net_rate = item.qty * item.sales_incoming_rate
# Material of a transfer goes back at the rate it came in with, the rejected
# material along with the accepted.
net_rate = (flt(item.qty) + flt(item.rejected_qty)) * item.sales_incoming_rate
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
warehouse_debit_amount = flt(
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
# The rejected warehouse carries the rest of what the invoice paid for, and is booked
# by its own entry, so it is not a variance.
returned_stock_value = warehouse_debit_amount + flt(
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)), net_amt_precision
)
if flt(stock_amount, net_amt_precision) != flt(returned_stock_value, net_amt_precision):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
stock_adjustment_amt = stock_amount - returned_stock_value
gl_entries.append(
self.get_gl_dict(

View File

@@ -844,7 +844,9 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name, do_not_save=True)
pi.credit_to = "_Test Payable USD - _TC"
pi.submit()
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
existing_purchase_cost + 15000,
@@ -856,12 +858,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
existing_purchase_cost + 15500,
)
pi1.reload()
pi1.cancel()
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
existing_purchase_cost + 15000,
)
pi.reload()
pi.cancel()
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost
@@ -2624,6 +2628,582 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
def test_internal_transfer_invoice_with_rejected_qty(self):
"""An invoice that updates stock moves rejected material out of the in-transit warehouse and
books it, like a receipt does."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries,
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Invoice Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Invoice Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Invoice Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Invoice Transfer Rejected", company=company)
item_doc = create_item("Test Invoice Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 7
pi.items[0].rejected_qty = 3
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
booked_value = {}
for entry in get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True):
booked_value.setdefault(entry.account, 0)
booked_value[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked_value.values()), 2), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
def test_internal_transfer_invoice_with_rejected_batch_qty(self):
"""Batch material rejected on a stock updating internal transfer invoice gets its own package."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Batch Invoice Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Batch Invoice Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Batch Invoice Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Batch Invoice Transfer Rejected", company=company)
item = make_item(
"Test Invoice Internal Transfer Batch Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TIITB-.####",
},
)
make_purchase_receipt(
item_code=item.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 7
pi.items[0].rejected_qty = 3
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
row = pi.items[0]
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty"), 7
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "total_qty"),
3,
)
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(moved_qty[transit_warehouse], -10)
self.assertEqual(moved_qty[to_warehouse], 7)
self.assertEqual(moved_qty[rejected_warehouse], 3)
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
self.assertEqual(flt(moved_value[to_warehouse]), 700)
self.assertEqual(flt(moved_value[rejected_warehouse]), 300)
def test_internal_transfer_invoice_with_every_unit_rejected(self):
"""An invoice may reject a whole row, and the in-transit warehouse is emptied all the same."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Invoice From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Invoice Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Invoice To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Invoice Rejected", company=company)
item_doc = create_item("Test Fully Rejected Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 0
pi.items[0].rejected_qty = 10
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertNotIn(to_warehouse, moved_qty)
self.assertEqual(moved_qty[transit_warehouse], -10)
self.assertEqual(moved_qty[rejected_warehouse], 10)
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
self.assertEqual(flt(moved_value[rejected_warehouse]), 1000)
def test_return_of_an_internal_transfer_invoice_that_rejected_everything(self):
"""Material rejected in full goes back to the in-transit warehouse at the rate it came in
with, and the entries say the same as the stock."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Returned Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Returned Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Returned Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Returned Transfer Rejected", company=company)
item_doc = create_item("Test Returned Fully Rejected Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 0
pi.items[0].rejected_qty = 10
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
returned = make_return_doc("Purchase Invoice", pi.name)
returned.update_stock = 1
returned.submit()
moved = {
d.warehouse: flt(d.stock_value_difference)
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
}
self.assertEqual(moved[transit_warehouse], 1000)
self.assertEqual(moved[rejected_warehouse], -1000)
booked = {}
for entry in frappe.get_all(
"GL Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
):
booked.setdefault(entry.account, 0)
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked.values()), 2), 0)
self.assertEqual(booked[get_inventory_account(company, transit_warehouse)], 1000)
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -1000)
def test_stock_updating_invoice_rejects_every_unit_of_a_row(self):
"""A row of a stock updating invoice may be rejected in full."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1}).name
rejected_warehouse = create_warehouse("_Test Fully Rejected Invoice Warehouse", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=0,
rejected_qty=10,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
do_not_save=True,
)
pi.submit()
moved_qty = {
d.warehouse: d.actual_qty
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty"],
)
}
self.assertEqual(moved_qty, {rejected_warehouse: 10})
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_stock_updating_invoice_bills_the_rejected_quantity(self):
"""With the rejected quantity billed and valued, the invoice pays for every unit received and
the stock it moves matches the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Billed Rejected Warehouse", company=company)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
self.assertEqual(pi.items[0].amount, 1000)
self.assertEqual(pi.items[0].valuation_rate, 100)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 400)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), 1000)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_rejected_material_is_reposted_after_the_setting_changes(self):
"""The entries an invoice books follow the stock it moved, so they can be built again once
the settings have moved on."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Repost Rejected", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=6,
rejected_qty=4,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
rebuilt = pi.get_gl_entries()
rejected_account = get_inventory_account(company, rejected_warehouse)
self.assertEqual(
flt(sum(flt(entry.get("debit")) - flt(entry.get("credit")) for entry in rebuilt), 2), 0
)
self.assertEqual(
flt(
sum(flt(entry.get("debit")) for entry in rebuilt if entry.get("account") == rejected_account)
),
400,
)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_return_without_a_reference_books_both_warehouses(self):
"""A return that stands on its own gives back the rejected material too, and books it once."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
accepted_warehouse = create_warehouse("_Test Invoice Return Accepted", company=company)
rejected_warehouse = create_warehouse("_Test Invoice Return Rejected", company=company)
def make_invoice(sign):
return make_purchase_invoice(
company=company,
item_code=item,
warehouse=accepted_warehouse,
qty=6 * sign,
rejected_qty=4 * sign,
received_qty=10 * sign,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
is_return=1 if sign < 0 else 0,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
)
make_invoice(1)
returned = make_invoice(-1)
booked = {}
for entry in frappe.get_all(
"GL Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
):
booked.setdefault(entry.account, 0)
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked.values()), 2), 0)
self.assertEqual(booked[get_inventory_account(company, accepted_warehouse)], -600)
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -400)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_discount_on_an_invoice_that_bills_the_rejected_quantity(self):
"""A discount is spread over every unit the invoice pays for, not the accepted ones alone."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Discount Rejected", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=6,
rejected_qty=4,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
do_not_save=True,
)
pi.apply_discount_on = "Net Total"
pi.additional_discount_percentage = 10
pi.submit()
self.assertEqual(pi.items[0].amount, 1000)
self.assertEqual(pi.items[0].net_rate, 90)
self.assertEqual(pi.grand_total, 900)
self.assertEqual(frappe.db.get_value("Item", item, "last_purchase_rate"), 90)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"set_valuation_rate_for_rejected_materials": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
"""An invoice that does not bill the rejected quantity has nothing to pay for that material,
so it carries no cost and the stock the invoice moves matches the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Rejected Warehouse", company=company)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
self.assertEqual(pi.items[0].amount, 600)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 0)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), sum(by_warehouse.values()))
def test_purchase_invoice_with_use_serial_batch_field_for_rejected_qty(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

View File

@@ -1,10 +1,15 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Purchase Taxes and Charges Template");
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
frappe.ui.form.on("Purchase Taxes and Charges Template", {
setup: function (frm) {
frm.cscript.tax_table = "Purchase Taxes and Charges";
},
});
frappe.ui.form.on("Purchase Taxes and Charges", {
add_deduct_tax(doc, cdt, cdn) {
let d = locals[cdt][cdn];

View File

@@ -3,8 +3,6 @@
frappe.provide("erpnext.accounts");
cur_frm.cscript.tax_table = "Sales Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Sales Invoice");
erpnext.accounts.payment_triggers.setup("Sales Invoice");
erpnext.accounts.pos.setup("Sales Invoice");
@@ -127,7 +125,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
if (should_create_delivery_note) {
this.frm.add_custom_button(
__("Delivery Note"),
this.frm.cscript["Make Delivery Note"],
() => this.make_delivery_note(),
__("Create")
);
}
@@ -732,28 +730,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
is_return() {
this.toggle_get_items();
}
make_delivery_note() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
frm: this.frm,
});
}
};
// for backward compatibility: combine new and previous states
extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ frm: cur_frm }));
frappe.ui.form.set_controller("Sales Invoice", erpnext.accounts.SalesInvoiceController);
cur_frm.cscript["Make Delivery Note"] = function () {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
frm: cur_frm,
});
};
frappe.ui.form.on("Sales Invoice Item", {
income_account: function (frm, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "income_account");
},
cur_frm.cscript.income_account = function (doc, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "income_account");
};
cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
};
expense_account: function (frm, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "expense_account");
},
});
frappe.ui.form.on("Sales Invoice", {
setup: function (frm) {
frm.cscript.tax_table = "Sales Taxes and Charges";
frm.add_fetch("customer", "tax_id", "tax_id");
frm.add_fetch("payment_term", "invoice_portion", "invoice_portion");
frm.add_fetch("payment_term", "description", "description");

View File

@@ -1,6 +1,11 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
cur_frm.cscript.tax_table = "Sales Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Sales Taxes and Charges Template");
erpnext.accounts.taxes.setup_tax_filters("Sales Taxes and Charges");
frappe.ui.form.on("Sales Taxes and Charges Template", {
setup: function (frm) {
frm.cscript.tax_table = "Sales Taxes and Charges";
},
});

View File

@@ -47,6 +47,10 @@
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "set_valuation_rate_for_rejected_materials",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"

View File

@@ -6,8 +6,9 @@ from datetime import date
import frappe
from frappe import _, msgprint, qb, scrub
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.model.utils import get_fetch_values
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder.functions import Abs, Date, Sum
from frappe.utils import (
add_days,
@@ -159,7 +160,7 @@ def _get_party_details(
)
set_contact_details(party_details, party, party_type, doctype)
set_other_values(party_details, party, party_type)
set_price_list(party_details, party, party_type, price_list, pos_profile)
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
tax_template = set_taxes(
party.name,
@@ -408,13 +409,33 @@ def get_default_price_list(party):
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
def get_permitted_price_lists(doctype=None):
permissions = sorted(
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
)
# a permission applicable for another doctype doesn't restrict this transaction
return get_allowed_docs_for_doctype(permissions, doctype)
def get_usable_price_list(price_lists, party_doctype):
transaction_side = "selling" if party_doctype == "Customer" else "buying"
for price_list in price_lists:
details = frappe.get_cached_value(
"Price List", price_list, ["enabled", transaction_side], as_dict=True
)
if details.enabled and details[transaction_side]:
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
# price list
price_list = get_permitted_documents("Price List")
permitted_price_lists = get_permitted_price_lists(doctype)
# if there is only one permitted document based on user permissions, set it
if price_list and len(price_list) == 1:
price_list = price_list[0]
if len(permitted_price_lists) == 1:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
@@ -426,6 +447,10 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
else:
price_list = get_default_price_list(party) or given_price_list
# don't set a price list the user has no permission for, the transaction can't be saved with it
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list and not is_price_list_enabled(price_list):
price_list = None

View File

@@ -1178,7 +1178,8 @@ class ReceivablePayableReport:
self.add_column(
label=_("Party Type"),
fieldname="party_type",
fieldtype="Data",
fieldtype="Link",
options="DocType",
width=100,
)
self.add_column(
@@ -1229,7 +1230,9 @@ class ReceivablePayableReport:
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project")
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
self.add_column(
label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Link", options="DocType"
)
self.add_column(
label=_("Voucher No"),
fieldname="voucher_no",

View File

@@ -140,7 +140,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(
label=_("Party Type"),
fieldname="party_type",
fieldtype="Data",
fieldtype="Link",
options="DocType",
width=100,
)
self.add_column(

View File

@@ -25,7 +25,8 @@ def get_columns():
{
"label": _("Payment Document Type"),
"fieldname": "payment_document_type",
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"width": 130,
},
{

View File

@@ -66,7 +66,8 @@ def get_columns():
{
"fieldname": "payment_document",
"label": _("Payment Document Type"),
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"width": 220,
},
{

View File

@@ -126,7 +126,8 @@ def get_columns():
{
"fieldname": "payment_document",
"label": _("Payment Document Type"),
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"width": 220,
},
{

View File

@@ -211,7 +211,8 @@ class General_Payment_Ledger_Comparison:
dict(
label=_("Voucher Type"),
fieldname="voucher_type",
fieldtype="Data",
fieldtype="Link",
options="DocType",
width="100",
)
)
@@ -230,7 +231,8 @@ class General_Payment_Ledger_Comparison:
dict(
label=_("Party Type"),
fieldname="party_type",
fieldtype="Data",
fieldtype="Link",
options="DocType",
width="100",
)
)

View File

@@ -558,7 +558,6 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
for gle in gl_entries:
group_by_value = gle.get(group_by)
gle.voucher_subtype = _(gle.voucher_subtype)
gle.against_voucher_type = _(gle.against_voucher_type)
gle.remarks = _(gle.remarks)
gle.party_type = _(gle.party_type)
@@ -757,7 +756,13 @@ def get_columns(filters):
]
columns += [
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 120},
{
"label": _("Voucher Type"),
"fieldname": "voucher_type",
"fieldtype": "Link",
"options": "DocType",
"width": 120,
},
{
"label": _("Voucher Subtype"),
"fieldname": "voucher_subtype",
@@ -802,7 +807,13 @@ def get_columns(filters):
columns.extend(
[
{"label": _("Against Voucher Type"), "fieldname": "against_voucher_type", "width": 100},
{
"label": _("Against Voucher Type"),
"fieldname": "against_voucher_type",
"fieldtype": "Link",
"options": "DocType",
"width": 100,
},
{
"label": _("Against Voucher"),
"fieldname": "against_voucher",

View File

@@ -154,7 +154,11 @@ class PaymentLedger:
self.columns.append(
dict(
label=_("Party Type"), fieldname="party_type", fieldtype="data", options=options, width="100"
label=_("Party Type"),
fieldname="party_type",
fieldtype="Link",
options="DocType",
width="100",
)
)
self.columns.append(
@@ -164,8 +168,8 @@ class PaymentLedger:
dict(
label=_("Voucher Type"),
fieldname="voucher_type",
fieldtype="data",
options=options,
fieldtype="Link",
options="DocType",
width="100",
)
)
@@ -182,8 +186,8 @@ class PaymentLedger:
dict(
label=_("Against Voucher Type"),
fieldname="against_voucher_type",
fieldtype="data",
options=options,
fieldtype="Link",
options="DocType",
width="100",
)
)

View File

@@ -82,7 +82,8 @@ def get_columns(filters):
{
"fieldname": "payment_document",
"label": _("Payment Document Type"),
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"width": 100,
},
{
@@ -92,7 +93,13 @@ def get_columns(filters):
"options": "payment_document",
"width": 160,
},
{"fieldname": "party_type", "label": _("Party Type"), "fieldtype": "Data", "width": 100},
{
"fieldname": "party_type",
"label": _("Party Type"),
"fieldtype": "Link",
"options": "DocType",
"width": 100,
},
{
"fieldname": "party",
"label": _("Party"),

View File

@@ -175,6 +175,8 @@ def get_columns(invoice_list, additional_table_columns, include_payments=False):
{
"label": _("Voucher Type"),
"fieldname": "voucher_type",
"fieldtype": "Link",
"options": "DocType",
"width": 120,
},
{

View File

@@ -196,6 +196,8 @@ def get_columns(invoice_list, additional_table_columns, include_payments=False):
{
"label": _("Voucher Type"),
"fieldname": "voucher_type",
"fieldtype": "Link",
"options": "DocType",
"width": 120,
},
{

View File

@@ -256,7 +256,8 @@ class TaxWithholdingDetailsReport:
{
"label": _("Transaction Type"),
"fieldname": "transaction_type",
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"width": 130,
},
{

View File

@@ -14,7 +14,13 @@ def execute(filters=None):
def get_columns():
return [
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 300},
{
"label": _("Voucher Type"),
"fieldname": "voucher_type",
"fieldtype": "Link",
"options": "DocType",
"width": 300,
},
{
"label": _("Voucher No"),
"fieldname": "voucher_no",

View File

@@ -91,6 +91,8 @@ class ChildItemUpdater:
).format(child_item.idx, child_item.item_code)
)
self._validate_blanket_order_is_open(child_item, d)
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
if flt(child_item.get("qty")) != flt(d.get("qty")):
@@ -280,6 +282,19 @@ class ChildItemUpdater:
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
def _validate_blanket_order_is_open(self, child_item, new_data: dict) -> None:
if not child_item.get("blanket_order"):
return
new_stock_qty = flt(
flt(new_data.get("qty")) * flt(new_data.get("conversion_factor")),
child_item.precision("stock_qty"),
)
if new_stock_qty > flt(child_item.stock_qty):
blanket_order = frappe.get_doc("Blanket Order", child_item.blanket_order, for_update=True)
blanket_order.validate_can_be_ordered(self.parent.transaction_date)
blanket_order.validate_items_are_open([child_item.item_code])
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
frappe.throw(

View File

@@ -1,5 +1,6 @@
import frappe
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.party import get_default_price_list, set_price_list
from erpnext.tests.utils import ERPNextTestSuite
@@ -34,19 +35,159 @@ class PartyTestCase(ERPNextTestSuite):
self.assertIsNone(party_details.selling_price_list)
def create_price_list(self, enabled):
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
)
self.assertEqual(party_details.selling_price_list, permitted_default)
def test_permitted_given_price_list_should_be_kept(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, permitted_other)
def test_permission_for_another_doctype_should_not_apply(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
user = self.create_user_with_price_list_permissions([buying_price_list])
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def test_buying_transaction_should_not_take_a_selling_price_list(self):
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
user = self.create_user_with_price_list_permissions(permitted)
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
supplier = self.create_supplier(default_price_list=supplier_price_list)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
self.assertIsNone(party_details.buying_price_list)
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_pos_price_list_should_be_kept(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted)
pos_price_list = self.create_price_list(enabled=1)
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
)
self.assertEqual(party_details.selling_price_list, pos_price_list)
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
user = self.create_user_with_price_list_permissions(permitted)
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
user = frappe.get_doc(
{
"doctype": "User",
"email": f"{frappe.generate_hash(length=10)}@example.com",
"first_name": "Price List Test",
"send_welcome_email": 0,
"roles": [{"role": "Sales User"}],
}
).insert(ignore_permissions=True)
for idx, price_list in enumerate(price_lists):
frappe.get_doc(
{
"doctype": "User Permission",
"user": user.name,
"allow": "Price List",
"for_value": price_list,
"is_default": int(idx == 0),
"apply_to_all_doctypes": int(not applicable_for),
"applicable_for": applicable_for,
}
).insert(ignore_permissions=True)
frappe.clear_cache(user=user.name)
self.addCleanup(frappe.clear_cache, user=user.name)
return user.name
def create_price_list(self, enabled, selling=1, buying=0):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": 1,
"selling": selling,
"buying": buying,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_supplier(self, **values):
return frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
def create_customer(self, **values):
customer = frappe.get_doc(
{

View File

@@ -485,4 +485,4 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
}
};
cur_frm.cscript = new erpnext.assets.AssetCapitalization({ frm: cur_frm });
frappe.ui.form.set_controller("Asset Capitalization", erpnext.assets.AssetCapitalization);

View File

@@ -40,7 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
"options": "Asset",
"reqd": 1
},

View File

@@ -231,9 +231,17 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
last_depr_date = add_months(
self.fb_row.depreciation_start_date, -1 * self.fb_row.frequency_of_depreciation
)
if self.has_complete_opening_periods() and self.should_get_last_day:
last_depr_date = get_last_day(last_depr_date)
return last_depr_date
def get_booked_depr_for_months_count(self, last_depr_date):
if last_depr_date and self.has_complete_opening_periods():
first_period_end = self.get_next_schedule_date(
-cint(self.asset_doc.opening_number_of_booked_depreciations)
)
return month_diff(last_depr_date, first_period_end) - 1 + self.fb_row.frequency_of_depreciation
depr_booked_for_months = 0
if last_depr_date:
asset_used_for_months = self.fb_row.frequency_of_depreciation * (
@@ -249,6 +257,20 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
)
return depr_booked_for_months
def has_complete_opening_periods(self):
if (
self.fb_row.depreciation_method not in ("Straight Line", "Manual")
or self.fb_row.daily_prorata_based
or self.fb_row.shift_based
or not self.asset_doc.opening_number_of_booked_depreciations
):
return False
first_period_start = add_days(
self.get_next_schedule_date(-cint(self.asset_doc.opening_number_of_booked_depreciations) - 1), 1
)
return first_period_start == getdate(self.asset_doc.available_for_use_date)
def get_total_pending_days_or_years(self):
if cint(frappe.get_single_value("Accounts Settings", "calculate_depr_using_total_days")):
last_depr_date = self.get_last_booked_depreciation_date()

View File

@@ -2,7 +2,7 @@
# See license.txt
import frappe
from frappe.utils import cstr, flt, getdate
from frappe.utils import add_months, cstr, flt, get_last_day, getdate
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.assets.doctype.asset.depreciation import (
@@ -21,6 +21,30 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAssetDepreciationSchedule(ERPNextTestSuite):
def test_complete_opening_periods(self):
for available in ("2026-08-01", "2024-02-01"):
for frequency in (1, 3, 6, 12):
for opening in (1, 4):
with self.subTest(available=available, frequency=frequency, opening=opening):
start = get_last_day(add_months(available, (opening + 1) * frequency - 1))
asset = create_asset(
calculate_depreciation=1,
available_for_use_date=available,
depreciation_start_date=start,
frequency_of_depreciation=frequency,
total_number_of_depreciations=12,
net_purchase_amount=13200,
expected_value_after_useful_life=1200,
opening_number_of_booked_depreciations=opening,
opening_accumulated_depreciation=1000 * opening,
)
schedule = get_depr_schedule(asset.name, "Draft")
self.assertEqual(
[row.depreciation_amount for row in schedule], [1000] * (12 - opening)
)
self.assertEqual(schedule[-1].accumulated_depreciation_amount, 12000)
self.assertEqual(getdate(schedule[0].schedule_date), start)
def test_throw_error_if_another_asset_depr_schedule_exist(self):
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, submit=1)

View File

@@ -139,7 +139,7 @@
},
{
"default": "1",
"description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.",
"description": "If checked, the rejected quantity is billed anywhere in the purchase cycle.",
"fieldname": "bill_for_rejected_quantity_in_purchase_invoice",
"fieldtype": "Check",
"label": "Bill for rejected quantity in Purchase Invoice"
@@ -247,7 +247,7 @@
{
"default": "0",
"depends_on": "bill_for_rejected_quantity_in_purchase_invoice",
"description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.",
"description": "If enabled, the system will generate an accounting entry for material rejected anywhere in the purchase cycle.",
"fieldname": "set_valuation_rate_for_rejected_materials",
"fieldtype": "Check",
"label": "Set valuation rate for rejected Materials"

View File

@@ -77,3 +77,45 @@ class BuyingSettings(Document):
def check_maintain_same_rate(self):
if self.maintain_same_rate:
self.set_landed_cost_based_on_purchase_invoice_rate = 0
def is_rejected_material_valued(voucher_type: str, voucher_detail_no: str | None = None) -> bool:
"""Rejected material carries stock value only when something has paid for it.
Material of an internal transfer always has: its value was credited out of the in-transit
warehouse. A Purchase Receipt books rejected material against Stock Received But Not Billed, so
the supplier still owes an invoice for it, and Buying Settings decides. A stock updating Purchase
Invoice pays for it only when it bills the received qty, which is what the settings ask for.
"""
if is_material_from_in_transit_warehouse(voucher_type, voucher_detail_no):
return True
if not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
return False
return voucher_type != "Purchase Invoice" or bool(
frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
)
def is_material_from_in_transit_warehouse(voucher_type: str, voucher_detail_no: str | None) -> bool:
if voucher_type not in ("Purchase Receipt", "Purchase Invoice") or not voucher_detail_no:
return False
return bool(frappe.get_cached_value(voucher_type + " Item", voucher_detail_no, "from_warehouse"))
def bills_rejected_quantity(doc) -> bool:
"""An invoice that moves stock itself has no receipt to bill the rejected material for it, so it
bills the received qty when the settings ask for the material to be valued.
An internal transfer bills nothing of the sort: its material is paid for by the warehouse it came
out of.
"""
if doc.doctype != "Purchase Invoice" or not doc.get("update_stock"):
return False
if doc.get("is_internal_supplier") and doc.get("represents_company") == doc.get("company"):
return False
return is_rejected_material_valued(doc.doctype)

View File

@@ -4,14 +4,14 @@
frappe.provide("erpnext.buying");
frappe.provide("erpnext.accounts.dimensions");
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
erpnext.accounts.taxes.setup_tax_validations("Purchase Order");
erpnext.buying.setup_buying_controller();
frappe.ui.form.on("Purchase Order", {
setup: function (frm) {
frm.cscript.tax_table = "Purchase Taxes and Charges";
frm.set_indicator_formatter("item_code", function (doc) {
let color;
if (doc.closed) {
@@ -826,21 +826,20 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
items_on_form_rendered() {
set_schedule_date(this.frm);
}
update_status(label, status) {
frappe.call({
method: "erpnext.buying.doctype.purchase_order.purchase_order.update_status",
args: { status: status, name: this.frm.doc.name },
callback: () => {
this.frm.set_value("status", status);
this.frm.reload_doc();
},
});
}
};
// for backward compatibility: combine new and previous states
extend_cscript(cur_frm.cscript, new erpnext.buying.PurchaseOrderController({ frm: cur_frm }));
cur_frm.cscript.update_status = function (label, status) {
frappe.call({
method: "erpnext.buying.doctype.purchase_order.purchase_order.update_status",
args: { status: status, name: cur_frm.doc.name },
callback: function (r) {
cur_frm.set_value("status", status);
cur_frm.reload_doc();
},
});
};
frappe.ui.form.set_controller("Purchase Order", erpnext.buying.PurchaseOrderController);
function set_schedule_date(frm) {
if (frm.doc.schedule_date) {

View File

@@ -1,8 +1,6 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
cur_frm.add_fetch("contact", "email_id", "email_id");
erpnext.buying.setup_buying_controller();
frappe.ui.form.on("Request for Quotation", {
@@ -585,5 +583,4 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
}
};
// for backward compatibility: combine new and previous states
extend_cscript(cur_frm.cscript, new erpnext.buying.RequestforQuotationController({ frm: cur_frm }));
frappe.ui.form.set_controller("Request for Quotation", erpnext.buying.RequestforQuotationController);

View File

@@ -117,7 +117,7 @@ frappe.ui.form.on("Supplier", {
if (frappe.defaults.get_default("supp_master_name") != "Naming Series") {
frm.toggle_display("naming_series", false);
} else {
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
}
if (frm.doc.__islocal) {

View File

@@ -127,5 +127,4 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
}
};
// for backward compatibility: combine new and previous states
extend_cscript(cur_frm.cscript, new erpnext.buying.SupplierQuotationController({ frm: cur_frm }));
frappe.ui.form.set_controller("Supplier Quotation", erpnext.buying.SupplierQuotationController);

View File

@@ -349,12 +349,26 @@ class AccountsController(TransactionBase):
self.validate_company_in_accounting_dimension()
def validate_price_list(self):
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
if self.get("selling_price_list"):
price_list_field, transaction_side = "selling_price_list", "selling"
else:
price_list_field, transaction_side = "buying_price_list", "buying"
price_list = self.get(price_list_field)
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
if not price_list:
return
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
details = (
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
or frappe._dict()
)
# An internal transfer carries the price list of the outward document into the inward one.
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
if details.enabled and fits_transaction:
return
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
if (
self.get("is_return")
and self.get("return_against")
@@ -365,9 +379,20 @@ class AccountsController(TransactionBase):
):
return
if not details.enabled:
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
if transaction_side == "selling":
message = _("Price List {0} cannot be used on a selling transaction")
else:
message = _("Price List {0} cannot be used on a buying transaction")
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
message.format(get_link_to_form("Price List", price_list)),
title=_("Invalid Price List"),
)
def set_default_letter_head(self):
@@ -724,12 +749,15 @@ class AccountsController(TransactionBase):
args = "for_buying"
if self.meta.get_field(fieldname) and self.get(fieldname):
previous_price_list_currency = self.price_list_currency
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
if self.price_list_currency == self.company_currency:
self.plc_conversion_rate = 1.0
elif not self.plc_conversion_rate:
elif not self.plc_conversion_rate or (
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
):
self.plc_conversion_rate = get_exchange_rate(
self.price_list_currency, self.company_currency, transaction_date, args
)
@@ -974,7 +1002,7 @@ class AccountsController(TransactionBase):
def validate_zero_qty_for_return_invoices_with_stock(self):
rows = []
for item in self.items:
if not flt(item.qty):
if not (flt(item.qty) or flt(item.get("rejected_qty"))):
rows.append(item)
if rows:
frappe.throw(
@@ -983,12 +1011,18 @@ class AccountsController(TransactionBase):
).format(frappe.bold(comma_and(["#" + str(x.idx) for x in rows])))
)
def is_stock_receipt(self) -> bool:
"""Whether this document receives material into a warehouse."""
return self.doctype == "Purchase Receipt" or (
self.doctype == "Purchase Invoice" and self.update_stock
)
def validate_qty_is_not_zero(self):
if self.flags.allow_zero_qty:
return
for item in self.items:
if self.doctype == "Purchase Receipt" and item.rejected_qty:
if self.is_stock_receipt() and item.get("rejected_qty"):
continue
if not flt(item.qty):

View File

@@ -14,16 +14,21 @@ import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import _get_party_details
from erpnext.buying.doctype.buying_settings.buying_settings import (
bills_rejected_quantity,
is_rejected_material_valued,
)
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
get_conversion_factor,
get_item_defaults,
)
from erpnext.stock.utils import _get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate, is_serial_no_wise_valuation_disabled
class QtyMismatchError(ValidationError):
@@ -67,6 +72,7 @@ class BuyingController(SubcontractingController):
if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
self.update_valuation_rate()
self.sync_accepted_packages()
self.set_serial_and_batch_bundle()
def onload(self):
@@ -146,13 +152,10 @@ class BuyingController(SubcontractingController):
for item in self.get("items"):
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
item.serial_and_batch_bundle = self.make_package_for_transfer(
bundle_ids.get(item.get(field)),
item.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=item.qty,
item.serial_and_batch_bundle = self.make_accepted_package(
item, bundle_ids.get(item.get(field))
)
elif (
not self.is_new()
and item.serial_and_batch_bundle
@@ -175,6 +178,138 @@ class BuyingController(SubcontractingController):
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
if item.get(field) and bundle_ids.get(item.get(field)):
self.set_rejected_package(item, bundle_ids.get(item.get(field)))
def make_accepted_package(self, row, package) -> str:
"""Package of the material the row accepts.
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
row that rejects material needs a package of the accepted warehouse instead, since that is
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
own when the receipt is submitted.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=flt(row.stock_qty),
)
if not flt(row.stock_qty):
return ""
return self.make_package_for_transfer(
package,
row.warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=flt(row.stock_qty),
exclude_serial_nos=self.get_rejected_serial_nos(row),
)
def get_delivered_package(self, row) -> str | None:
"""Package of the material the delivery note put in the in-transit warehouse."""
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
if not row.get(field):
return None
return frappe.db.get_value(doctype, row.get(field), "serial_and_batch_bundle")
def set_rejected_package(self, row, package) -> None:
"""Package of the material the row rejects.
A receipt of an internal transfer builds no package for it on its own, so rejected material
of a tracked item would have nothing to say where it came from.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)) or self.is_return:
return
if row.get("rejected_serial_and_batch_bundle") or not row.rejected_warehouse:
return
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
row.rejected_serial_and_batch_bundle = self.make_package_for_transfer(
package,
row.rejected_warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=rejected_qty,
exclude_serial_nos=self.get_accepted_serial_nos(row),
)
frappe.db.set_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "is_rejected", 1)
def get_accepted_serial_nos(self, row) -> list:
if not row.get("serial_and_batch_bundle"):
return []
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
def sync_accepted_packages(self) -> None:
"""Keep the package of a row in the shape its own entry needs.
A row that rejects material carries the package of its accepted warehouse; a row that
rejects nothing carries the package of the in-transit warehouse it came out of. Editing the
split moves the package from one to the other.
"""
if not self.is_internal_receipt() or self.is_return:
return
for row in self.get("items"):
package = row.get("serial_and_batch_bundle")
if not package:
continue
details = frappe.db.get_value(
"Serial and Batch Bundle",
package,
["warehouse", "type_of_transaction", "docstatus"],
as_dict=True,
)
if not details or details.docstatus != 0:
continue
if flt(row.rejected_qty):
wanted = (row.warehouse, "Inward")
else:
wanted = (row.from_warehouse, "Outward")
if (details.warehouse, details.type_of_transaction) == wanted:
continue
row.serial_and_batch_bundle = self.make_accepted_package(
row, self.get_delivered_package(row) or package
)
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
def get_internal_transfer_qty(self, row) -> float:
if flt(row.qty) or not self.is_internal_receipt():
return flt(row.qty)
return flt(row.rejected_qty)
def get_rejected_serial_nos(self, row) -> list:
if not flt(row.get("rejected_qty")):
return []
if row.get("rejected_serial_and_batch_bundle"):
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
return get_serial_nos(row.get("rejected_serial_no"))
def set_rate_for_standalone_debit_note(self):
if self.get("is_return") and self.get("update_stock") and not self.return_against:
for row in self.items:
@@ -466,7 +601,7 @@ class BuyingController(SubcontractingController):
net_rate = item.base_net_amount
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
if (
not net_rate
@@ -477,7 +612,7 @@ class BuyingController(SubcontractingController):
):
net_rate = item.rejected_qty * item.net_rate
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
qty_in_stock_uom = flt(self.get_valued_qty(item) * item.conversion_factor)
if not qty_in_stock_uom and item.get("rejected_qty"):
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
@@ -492,6 +627,14 @@ class BuyingController(SubcontractingController):
update_regional_item_valuation_rate(self)
def get_valued_qty(self, row):
"""Quantity the net amount of the row was billed for, which is what its valuation spreads
over."""
if not flt(row.get("rejected_qty")) or not bills_rejected_quantity(self):
return flt(row.qty)
return flt(row.qty) + flt(row.rejected_qty)
def get_tax_details(self):
tax_accounts = []
total_valuation_amount = 0.0
@@ -617,7 +760,11 @@ class BuyingController(SubcontractingController):
return
if cint(self.get("is_return")):
# Get outgoing rate based on original item cost based on valuation method
# Material of a transfer goes back at the rate it came in with. Anything else is
# valued from the original item cost by its valuation method.
if self.is_internal_transfer():
self.set_sales_incoming_rate_for_internal_transfer()
return
if not self.is_internal_transfer():
@@ -658,8 +805,13 @@ class BuyingController(SubcontractingController):
}
ref_doctype = ref_doctype_map.get(self.doctype)
returned_field = frappe.scrub(self.doctype) + "_item"
for d in self.get("items"):
if not d.get(frappe.scrub(ref_doctype)):
if self.get("is_return") and d.get(returned_field):
d.sales_incoming_rate = flt(
frappe.db.get_value(self.doctype + " Item", d.get(returned_field), "sales_incoming_rate")
)
elif not d.get(frappe.scrub(ref_doctype)):
posting_time = self.get("posting_time")
if not posting_time:
posting_time = nowtime()
@@ -754,6 +906,95 @@ class BuyingController(SubcontractingController):
)
)
def is_internal_receipt(self) -> bool:
return self.is_internal_transfer() and self.is_stock_receipt()
def get_source_warehouse_qty(self, row, accepted_qty):
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return accepted_qty
if row.get("serial_and_batch_bundle") and not row.get("rejected_serial_and_batch_bundle"):
return accepted_qty
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
def get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
"""Package for the entry into the accepted warehouse, which is the package of the row itself
when the row rejects material."""
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
return row.serial_and_batch_bundle
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
return self.get_package_for_target_warehouse(
row,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
return row.serial_and_batch_bundle
def get_submitted_package(self, row, warehouse):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
"serial_and_batch_bundle",
)
def get_source_warehouse_reversal_package(self, row, package):
if not (self.is_internal_transfer() and self.is_return):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not row.get("rejected_serial_and_batch_bundle"):
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
return self.get_returned_source_package(row)
def get_source_warehouse_package(self, row, package):
if not (row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not package:
return self.make_package_for_transfer(
row.rejected_serial_and_batch_bundle, row.from_warehouse, type_of_transaction="Outward"
)
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def get_package_of_source_warehouse(self, row) -> str | None:
return frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"warehouse": row.from_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
"name",
)
def get_returned_source_package(self, row):
return self.make_package_for_transfer(
row.serial_and_batch_bundle,
row.from_warehouse,
type_of_transaction="Inward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -764,114 +1005,114 @@ class BuyingController(SubcontractingController):
if d.item_code not in stock_items:
continue
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_reversal_sle = None
if pr_qty:
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
)
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": serial_and_batch_bundle,
},
if source_qty and (d.warehouse or not pr_qty):
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
)
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": (
d.serial_and_batch_bundle
if not self.is_internal_transfer()
or self.is_return
or (self.is_internal_transfer() and self.docstatus == 2)
else self.get_package_for_target_warehouse(
d,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": self.get_source_warehouse_package(
d, serial_and_batch_bundle
),
},
)
if self.is_return:
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": self.get_accepted_warehouse_package(
d, type_of_transaction, via_landed_cost_voucher
),
},
)
if self.is_return:
outgoing_rate = 0.0
if not is_serial_no_wise_valuation_disabled(d.item_code):
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.warehouse},
"serial_and_batch_bundle",
)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
reversed_warehouse = (
d.from_warehouse if d.get("rejected_serial_and_batch_bundle") else d.warehouse
)
serial_and_batch_bundle = self.get_submitted_package(d, reversed_warehouse)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": (
self.get_package_for_target_warehouse(d, d.from_warehouse, "Inward")
if self.is_internal_transfer() and self.is_return
else serial_and_batch_bundle
),
},
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": self.get_source_warehouse_reversal_package(
d, serial_and_batch_bundle
),
},
)
if self.is_internal_transfer() and self.is_return:
from_warehouse_sle.incoming_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sl_entries.append(from_warehouse_sle)
source_reversal_sle = from_warehouse_sle
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
if frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
if is_rejected_material_valued(self.doctype, d.name):
valuation_rate_for_rejected_item = d.valuation_rate
sl_entries.append(
@@ -889,6 +1130,9 @@ class BuyingController(SubcontractingController):
)
)
if source_reversal_sle:
sl_entries.append(source_reversal_sle)
self.make_sl_entries(
sl_entries,
allow_negative_stock=allow_negative_stock,

View File

@@ -20,6 +20,7 @@ REOPEN_STATUS = {
"Sales Order": "Draft",
"Delivery Note": "Submitted",
"Purchase Receipt": "Submitted",
"Blanket Order": "Submitted",
}
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
@@ -76,7 +77,10 @@ def update_closed_status(doctype: str, name: str, item_names: str | list[str], c
for row in changed:
row.db_set("closed", closed)
doc.on_item_close_status_change()
recalculate = getattr(doc, "on_item_close_status_change", None)
if recalculate:
recalculate()
doc.reload()
if closed:

View File

@@ -26,6 +26,7 @@ from pypika import Order
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.doctype.company_restriction.company_restriction import get_restriction_criterion
from erpnext.stock.doctype.item.item_search import get_item_search_candidates
from erpnext.stock.get_item_details import _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities.query import get_filter_conditions_qb
@@ -388,9 +389,11 @@ def item_query(
db_fields = [f.fieldname for f in meta.fields] + ["name"]
search_str = f"%{txt}%"
search_conditions = []
searched_fields = []
for fieldname in fields_to_process:
if fieldname in db_fields:
search_conditions.append(item[fieldname].like(search_str))
searched_fields.append(fieldname)
barcode_tbl = DocType("Item Barcode")
barcode_subquery = (
@@ -401,6 +404,9 @@ def item_query(
# Condition for the description
if frappe.db.estimate_count("Item") < 50000 and "description" not in fields_to_process:
search_conditions.append(item.description.like(search_str))
searched_fields.append("description")
candidates = get_item_search_candidates(txt, searched_fields)
txt_no_percent = txt.replace("%", "")
@@ -436,6 +442,11 @@ def item_query(
.offset(start)
)
if candidates is not None:
if not candidates:
return [] if as_dict else ()
query = query.where(item.name.isin(candidates))
if company:
query = query.where(get_restriction_criterion("Item", [company]))
@@ -822,16 +833,18 @@ def get_account_list(
def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
bo_filters = [
["docstatus", "=", 1],
["status", "!=", "Closed"],
["blanket_order_type", "=", filters.get("blanket_order_type")],
["company", "=", filters.get("company")],
]
if frappe.has_permission("Blanket Order", "read"):
bo_filters.append(["Blanket Order Item", "item_code", "=", filters.get("item")])
bo_filters.append(["Blanket Order Item", "closed", "=", 0])
else:
parents = frappe.get_all(
"Blanket Order Item",
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order"},
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order", "closed": 0},
pluck="parent",
distinct=True,
)
@@ -840,6 +853,9 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
if currency := filters.get("currency"):
bo_filters.append(["currency", "=", currency])
if transaction_date := filters.get("transaction_date"):
bo_filters.append(["to_date", ">=", transaction_date])
return frappe.get_list(
"Blanket Order",
filters=bo_filters,

View File

@@ -170,7 +170,7 @@ def validate_returned_items(doc):
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0 or flt(d.get("rejected_qty")) < 0:
items_returned = True
else:
items_returned = True
@@ -736,7 +736,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
if return_against_rejected_qty:
return doc.rejected_qty
return doc.qty
return doc.qty or doc.get("rejected_qty")
doclist = get_mapped_doc(
doctype,
@@ -885,8 +885,13 @@ def get_filters(
if reference_voucher_detail_no:
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
filters["warehouse"] = item_row.get("warehouse")
# A row that accepted nothing goes back at the rate the rejected warehouse received it at.
warehouse_field = "warehouse"
if not flt(item_row.get("qty")) and flt(item_row.get("rejected_qty")):
warehouse_field = "rejected_warehouse"
if item_row.get(warehouse_field) and item_row.get(warehouse_field) in warehouses:
filters["warehouse"] = item_row.get(warehouse_field)
return filters

View File

@@ -11,6 +11,7 @@ from erpnext.accounts.party import render_address
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
from erpnext.controllers.stock_controller import StockController
from erpnext.selling.doctype.customer.customer import is_customer_blocked
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
@@ -51,6 +52,7 @@ class SellingController(StockController):
def validate(self):
super().validate()
self.ensure_customer_is_not_blocked()
self.validate_items()
if not (self.get("is_debit_note") or self.get("is_return")):
self.validate_max_discount()
@@ -476,6 +478,13 @@ class SellingController(StockController):
so_warehouse = (so_item.warehouse if so_item else "") or ""
return so_qty, so_warehouse
def ensure_customer_is_not_blocked(self):
if self.doctype == "Quotation":
return
if self.customer and is_customer_blocked(self.customer):
frappe.throw(_("{0} is blocked so this transaction cannot proceed").format(self.customer))
def check_sales_order_on_hold_or_close(self, ref_fieldname):
if self.is_return:
return

View File

@@ -186,6 +186,12 @@ status_map = {
["Completed", "eval:self.purpose == 'Delivery' and self.delivery_status == 'Fully Delivered'"],
["Cancelled", "eval:self.docstatus == 2"],
],
"Blanket Order": [
["Draft", None],
["Submitted", "eval:self.docstatus == 1"],
["Closed", "eval:self.status == 'Closed' and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus == 2"],
],
}

View File

@@ -260,12 +260,25 @@ class StockController(AccountsController):
return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate)
def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
self,
serial_and_batch_bundle,
warehouse,
type_of_transaction=None,
do_not_submit=None,
qty=0,
include_bundle=None,
exclude_serial_nos=None,
):
from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService
return SerialBatchBundleService(self).make_package_for_transfer(
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
serial_and_batch_bundle,
warehouse,
type_of_transaction,
do_not_submit,
qty,
include_bundle,
exclude_serial_nos,
)
def get_sl_entries(self, d, args):
@@ -941,10 +954,20 @@ def make_bundle_for_material_transfer(**kwargs):
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
bundle_doc.is_cancelled = 0
if kwargs.include_bundle:
for entry in frappe.get_doc("Serial and Batch Bundle", kwargs.include_bundle).entries:
bundle_doc.append("entries", entry.as_dict(no_default_fields=True))
if kwargs.exclude_serial_nos:
keep = [row for row in bundle_doc.entries if row.serial_no not in set(kwargs.exclude_serial_nos)]
bundle_doc.entries = keep
for idx, row in enumerate(keep, start=1):
row.idx = idx
qty = 0
if (
len(bundle_doc.entries) == 1
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
and abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
and not bundle_doc.has_serial_no
):
qty = kwargs.qty

View File

@@ -13,6 +13,7 @@ from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
from erpnext.controllers.accounts_controller import (
validate_conversion_rate,
validate_inclusive_tax,
@@ -241,13 +242,19 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
item.amount = flt(item.rate * self.get_billed_qty(item), item.precision("amount"))
item.net_amount = item.amount
self._set_in_company_currency(
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
)
item.item_tax_amount = 0.0
def get_billed_qty(self, item):
if not flt(item.get("rejected_qty")) or not bills_rejected_quantity(self.doc):
return flt(item.qty)
return flt(item.qty) + flt(item.rejected_qty)
def _set_in_company_currency(self, doc, fields):
"""set values in base currency"""
for f in fields:
@@ -339,7 +346,7 @@ class calculate_taxes_and_totals:
item._unrounded_net_amount = amount / (1 + total_tax_slope)
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.net_rate = flt(item.net_amount / self.get_billed_qty(item), item.precision("net_rate"))
item.discount_percentage = flt(
item.discount_percentage, item.precision("discount_percentage")
)
@@ -945,8 +952,9 @@ class calculate_taxes_and_totals:
)
net_total += rounding_difference
billed_qty = self.get_billed_qty(item)
item.net_rate = (
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
flt(item.net_amount / billed_qty, item.precision("net_rate")) if billed_qty else 0
)
self._set_in_company_currency(item, ["net_rate", "net_amount"])

View File

@@ -0,0 +1,59 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# For license information, please see license.txt
from unittest.mock import Mock, patch
import frappe
from frappe.tests import UnitTestCase
from erpnext.controllers.accounts_controller import AccountsController
class TestPriceListCurrency(UnitTestCase):
def test_price_list_currency_transition(self):
cases = (
("USD", "CDF", 1, 0.000444444, True),
("EUR", "CDF", 1.2, 0.000444444, True),
("CDF", "USD", 0.000444444, 1, False),
("CDF", "CDF", 0.0005, 0.0005, False),
("CDF", "CDF", 0, 0.000444444, True),
(None, "CDF", 0.0005, 0.0005, False),
)
for direction in ("Selling", "Buying"):
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
with self.subTest(
direction=direction,
previous_currency=previous_currency,
currency=currency,
previous_rate=previous_rate,
):
doc = frappe._dict(
meta=Mock(),
posting_date="2026-09-18",
selling_price_list="New Selling Price List",
buying_price_list="New Buying Price List",
price_list_currency=previous_currency,
plc_conversion_rate=previous_rate,
company_currency="USD",
currency="CDF",
conversion_rate=0.000444444,
)
with (
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
patch(
"erpnext.controllers.accounts_controller.get_exchange_rate",
return_value=0.000444444,
) as exchange_rate,
):
mock_frappe.db.get_value.return_value = currency
mock_frappe.db.get_single_value.return_value = False
AccountsController.set_price_list_currency(doc, direction)
self.assertEqual(doc.price_list_currency, currency)
self.assertEqual(doc.plc_conversion_rate, expected_rate)
self.assertEqual(doc.conversion_rate, 0.000444444)
if fetch_rate:
exchange_rate.assert_called_once_with(
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
)
else:
exchange_rate.assert_not_called()

View File

@@ -0,0 +1,93 @@
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
class TestPriceListValidation(ERPNextTestSuite):
def create_price_list(self, selling=0, buying=0, enabled=1):
return (
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"enabled": enabled,
}
)
.insert()
.name
)
def test_selling_transaction_should_reject_a_buying_price_list(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(buying=1)
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
invoice.save()
def test_buying_transaction_should_reject_a_selling_price_list(self):
invoice = make_purchase_invoice(do_not_save=1)
invoice.buying_price_list = self.create_price_list(selling=1)
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
invoice.save()
def test_a_price_list_for_both_sides_should_be_accepted(self):
price_list = self.create_price_list(selling=1, buying=1)
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = price_list
invoice.save()
self.assertEqual(invoice.selling_price_list, price_list)
def test_a_missing_price_list_should_report_rather_than_crash(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = frappe.generate_hash(length=10)
with self.assertRaises(frappe.ValidationError):
invoice.validate_price_list()
def test_internal_transfer_should_keep_the_outward_price_list(self):
"""The inward document of an internal transfer takes the price list of the outward one, which
is flagged for the opposite side."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
selling_only = self.create_price_list(selling=1)
delivery_note = create_delivery_note(
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
warehouse="Stores - TCP1",
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
do_not_submit=1,
)
delivery_note.selling_price_list = selling_only
delivery_note.save()
delivery_note.submit()
receipt = make_inter_company_purchase_receipt(delivery_note.name)
receipt.items[0].warehouse = "Stores - TCP1"
receipt.save()
self.assertEqual(receipt.buying_price_list, selling_only)
def test_disabled_price_list_should_still_report_as_disabled(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
invoice.save()

View File

@@ -3,7 +3,7 @@
frappe.ui.form.on("Campaign", {
refresh: function (frm) {
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
if (frm.is_new()) {
frm.toggle_display(

View File

@@ -26,7 +26,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
refresh() {
var me = this;
let doc = this.frm.doc;
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(this.frm);
if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) {
this.frm.add_custom_button(__("Customer"), this.make_customer.bind(this), __("Create"));
@@ -240,6 +240,4 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
}
};
if (cur_frm) {
extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm }));
}
frappe.ui.form.set_controller("Lead", erpnext.LeadController);

View File

@@ -42,7 +42,7 @@ frappe.ui.form.on("Opportunity", {
erpnext.utils.map_current_doc({
method: "erpnext.crm.doctype.lead.mapper.make_opportunity",
source_name: frm.doc.party_name,
frm: frm,
target: frm,
});
}
},
@@ -73,7 +73,7 @@ frappe.ui.form.on("Opportunity", {
refresh: function (frm) {
var doc = frm.doc;
frm.trigger("setup_opportunity_from");
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
if (!frm.is_new() && doc.status !== "Lost") {
if (doc.items) {
@@ -269,6 +269,23 @@ frappe.ui.form.on("Opportunity Item", {
rate: function (frm, cdt, cdn) {
frm.trigger("calculate", cdt, cdn);
},
item_code: function (frm, cdt, cdn) {
let d = locals[cdt][cdn];
if (d.item_code) {
return frappe.call({
method: "erpnext.crm.doctype.opportunity.opportunity.get_item_details",
args: { item_code: d.item_code },
callback: function (r) {
if (r.message) {
$.each(r.message, function (k, v) {
frappe.model.set_value(cdt, cdn, k, v);
});
refresh_field("image_view", d.name, "items");
}
},
});
}
},
});
// TODO commonify this code
@@ -285,7 +302,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
this.frm.doc.opportunity_type = __("Sales");
this.frm.doc.opportunity_type = "Sales";
}
this.setup_queries();
}
@@ -372,22 +389,4 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
};
extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({ frm: cur_frm }));
cur_frm.cscript.item_code = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.item_code) {
return frappe.call({
method: "erpnext.crm.doctype.opportunity.opportunity.get_item_details",
args: { item_code: d.item_code },
callback: function (r, rt) {
if (r.message) {
$.each(r.message, function (k, v) {
frappe.model.set_value(cdt, cdn, k, v);
});
refresh_field("image_view", d.name, "items");
}
},
});
}
};
frappe.ui.form.set_controller("Opportunity", erpnext.crm.Opportunity);

View File

@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
def set_opportunity_type(self):
if self.is_new() and not self.opportunity_type:
self.opportunity_type = _("Sales")
self.opportunity_type = "Sales"
def set_exchange_rate(self):
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")

View File

@@ -32,7 +32,7 @@ def get_columns():
"label": _("Reference Document Type"),
"fieldname": "reference_document_type",
"fieldtype": "Link",
"options": "Doctype",
"options": "DocType",
"width": 100,
},
{

View File

@@ -387,6 +387,8 @@ pre_submit_validation_doctypes = [
"Sales Order",
]
sqlite_search = ["erpnext.stock.doctype.item.item_search.ItemSearch"]
doc_events = {
"*": {
"validate": [
@@ -404,6 +406,10 @@ doc_events = {
("Item", "Customer", "Supplier"): {
"validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies",
},
"Item": {
"on_update": "erpnext.stock.doctype.item.item_search.reindex_item",
"after_rename": "erpnext.stock.doctype.item.item_search.reindex_renamed_item",
},
"Stock Entry": {
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",

View File

@@ -163,4 +163,4 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap
}
};
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceSchedule({ frm: cur_frm }));
frappe.ui.form.set_controller("Maintenance Schedule", erpnext.maintenance.MaintenanceSchedule);

View File

@@ -145,4 +145,4 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui.
}
};
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceVisit({ frm: cur_frm }));
frappe.ui.form.set_controller("Maintenance Visit", erpnext.maintenance.MaintenanceVisit);

View File

@@ -11,6 +11,13 @@ frappe.ui.form.on("Blanket Order", {
},
setup: function (frm) {
frm.set_indicator_formatter("item_code", function (doc) {
if (doc.closed) {
return "gray";
}
return flt(doc.ordered_qty) >= flt(doc.qty) ? "green" : "orange";
});
frm.custom_make_buttons = {
"Purchase Order": "Purchase Order",
"Sales Order": "Sales Order",
@@ -26,7 +33,15 @@ frappe.ui.form.on("Blanket Order", {
refresh: function (frm) {
erpnext.hide_company(frm);
blanket_order_pricing.update_labels(frm);
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
add_blanket_order_status_buttons(frm);
erpnext.item_close.add_buttons(frm, get_blanket_order_item_close_config());
if (frm.doc.status === "Closed") {
return;
}
const can_order = frm.doc.docstatus === 1 && frm.doc.to_date >= frappe.datetime.get_today();
if (frm.doc.customer && can_order) {
frm.add_custom_button(
__("Sales Order"),
function () {
@@ -56,7 +71,7 @@ frappe.ui.form.on("Blanket Order", {
);
}
if (frm.doc.supplier && frm.doc.docstatus === 1) {
if (frm.doc.supplier && can_order) {
frm.add_custom_button(
__("Purchase Order"),
function () {
@@ -229,6 +244,56 @@ const blanket_order_pricing = {
},
};
function add_blanket_order_status_buttons(frm) {
if (frm.doc.docstatus !== 1 || !frm.has_perm("submit")) {
return;
}
if (frm.doc.status === "Closed") {
frm.add_custom_button(
__("Re-open"),
() => update_blanket_order_status(frm, "Submitted"),
__("Status")
);
} else {
frm.add_custom_button(__("Close"), () => update_blanket_order_status(frm, "Closed"), __("Status"));
}
}
function get_blanket_order_item_close_config() {
return {
is_closable: (item) => !item.closed && flt(item.ordered_qty) < flt(item.qty),
help: __(
"Closed rows can no longer be ordered. They are skipped when creating an order from this Blanket Order."
),
summarise: (item) => ({
item_code: item.item_code,
item_name: item.item_name,
qty: item.qty,
ordered_qty: item.ordered_qty || 0,
pending_qty: Math.max(flt(item.qty) - flt(item.ordered_qty), 0),
stock_uom: item.stock_uom,
}),
columns: [
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
erpnext.item_close.column("qty", __("Qty")),
erpnext.item_close.column("ordered_qty", __("Ordered Qty")),
erpnext.item_close.column("pending_qty", __("Pending Qty")),
erpnext.item_close.column("stock_uom", __("Stock UOM"), "Data"),
],
};
}
function update_blanket_order_status(frm, status) {
frappe.call({
method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.update_status",
args: { status: status, name: frm.doc.name },
freeze: true,
callback: () => frm.reload_doc(),
});
}
function reset_party_pricing(frm) {
return blanket_order_pricing.apply(frm, null, { reset_party_values: true });
}

View File

@@ -19,6 +19,7 @@
"from_date",
"to_date",
"company",
"status",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -105,6 +106,17 @@
"reqd": 1,
"search_index": 1
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nSubmitted\nClosed\nCancelled",
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
@@ -217,7 +229,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2026-09-23 20:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order",
@@ -268,6 +280,11 @@
"search_fields": "blanket_order_type, to_date",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"states": [
{
"color": "Green",
"title": "Closed"
}
],
"track_changes": 1
}

View File

@@ -4,18 +4,19 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from frappe.utils import flt, formatdate, getdate, today
from erpnext import get_company_currency
from erpnext.accounts.services.taxes import validate_conversion_rate
from erpnext.controllers.item_close import clear_closed_rows_on_amend, validate_parent_reopen
from erpnext.controllers.status_updater import StatusUpdater
from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing
from erpnext.stock.doctype.item.item import get_item_defaults
class BlanketOrder(Document):
class BlanketOrder(StatusUpdater):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -42,6 +43,7 @@ class BlanketOrder(Document):
plc_conversion_rate: DF.Float
price_list_currency: DF.Link | None
selling_price_list: DF.Link | None
status: DF.Literal["Draft", "Submitted", "Closed", "Cancelled"]
supplier: DF.Link | None
supplier_name: DF.Data | None
tc_name: DF.Link | None
@@ -60,6 +62,43 @@ class BlanketOrder(Document):
self.validate_item_qty()
self.set_party_item_code()
self.set_base_rates()
clear_closed_rows_on_amend(self)
def on_submit(self):
self.set_status(update=True)
def on_cancel(self):
self.set_status(update=True)
def update_status(self, status: str) -> None:
if status != "Closed" and self.status == "Closed":
validate_parent_reopen(self)
self.set_status(update=True, status=status)
self.notify_update()
def is_item_closable(self, item) -> bool:
return flt(item.ordered_qty) < flt(item.qty)
def validate_can_be_ordered(self, order_date) -> None:
self.validate_is_open()
self.validate_not_expired(order_date)
def validate_is_open(self) -> None:
if self.status == "Closed":
frappe.throw(
_("Blanket Order {0} is closed").format(frappe.bold(self.name)), frappe.InvalidStatusError
)
def validate_items_are_open(self, item_codes: list[str]) -> None:
for row in self.items:
if row.closed and row.item_code in item_codes:
frappe.throw(
_("Item {0} is closed in Blanket Order {1}").format(
frappe.bold(row.item_code), frappe.bold(self.name)
),
frappe.InvalidStatusError,
)
def set_currency(self):
if self.currency:
@@ -90,6 +129,15 @@ class BlanketOrder(Document):
if getdate(self.from_date) > getdate(self.to_date):
frappe.throw(_("From date cannot be greater than To date"))
def validate_not_expired(self, order_date) -> None:
if getdate(order_date) > getdate(self.to_date):
frappe.throw(
_("Blanket Order {0} expired on {1}").format(
frappe.bold(self.name), formatdate(self.to_date)
),
title=_("Blanket Order Expired"),
)
def set_party_item_code(self):
item_ref = {}
if self.blanket_order_type == "Selling":
@@ -184,8 +232,16 @@ def apply_price_list(
)
@frappe.whitelist(methods=["POST"])
def update_status(status: str, name: str):
blanket_order = frappe.get_doc("Blanket Order", name, check_permission="submit")
blanket_order.update_status(status)
@frappe.whitelist()
def make_order(source_name: str):
blanket_order = frappe.get_doc("Blanket Order", source_name, check_permission="read")
blanket_order.validate_can_be_ordered(today())
doctype = frappe.flags.args.doctype
def update_doc(source_doc, target_doc, source_parent):
@@ -219,7 +275,8 @@ def make_order(source_name: str):
"parent": "blanket_order",
},
"postprocess": update_item,
"condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
"condition": lambda item: not item.closed
and (not flt(item.qty) or (flt(item.qty) - flt(item.ordered_qty)) > 0),
},
},
)
@@ -235,7 +292,7 @@ def validate_against_blanket_order(order_doc):
order_data = {}
for item in order_doc.get("items"):
if item.against_blanket_order and item.blanket_order:
if item.blanket_order:
if item.blanket_order in order_data:
if item.item_code in order_data[item.blanket_order]:
order_data[item.blanket_order][item.item_code] += item.qty
@@ -251,8 +308,10 @@ def validate_against_blanket_order(order_doc):
"blanket_order_allowance",
)
)
for bo_name, item_data in order_data.items():
bo_doc = frappe.get_doc("Blanket Order", bo_name)
for bo_name, item_data in sorted(order_data.items()):
bo_doc = frappe.get_doc("Blanket Order", bo_name, for_update=True)
bo_doc.validate_can_be_ordered(order_doc.transaction_date)
bo_doc.validate_items_are_open(list(item_data))
for item in bo_doc.get("items"):
if item.item_code in item_data:
remaining_qty = item.qty - item.ordered_qty

View File

@@ -1,11 +1,14 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
from unittest.mock import patch
import frappe
from frappe.utils import add_months, flt, today
from frappe.utils import add_days, add_months, flt, today
from erpnext import get_company_currency
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.controllers.item_close import update_closed_status
from erpnext.controllers.queries import get_blanket_orders
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.get_item_details import get_blanket_order_details
@@ -147,6 +150,167 @@ class TestBlanketOrder(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, so.submit)
def test_status_follows_close_reopen_and_cancel(self):
bo = make_blanket_order(blanket_order_type="Selling")
self.assertEqual(bo.status, "Submitted")
bo.update_status("Closed")
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Closed")
bo.update_status("Submitted")
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Submitted")
bo.cancel()
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Cancelled")
def test_closed_blanket_order_cannot_be_ordered_against(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
po = make_purchase_order_against(bo, qty=10)
bo.update_status("Closed")
self.assertRaises(frappe.InvalidStatusError, make_order, bo.name)
self.assertRaises(frappe.InvalidStatusError, po.save)
filters = {"company": bo.company, "blanket_order_type": "Purchasing", "item": bo.items[0].item_code}
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertNotIn(bo.name, [order[0] for order in orders])
details = get_blanket_order_details(
{
"blanket_order": bo.name,
"company": bo.company,
"currency": bo.currency,
"supplier": bo.supplier,
"doctype": "Purchase Order",
"item_code": bo.items[0].item_code,
"transaction_date": today(),
}
)
self.assertFalse(details)
bo.update_status("Submitted")
po.save()
def test_linked_row_is_checked_without_against_blanket_order(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
po = make_purchase_order_against(bo, qty=10)
po.items[0].against_blanket_order = 0
bo.update_status("Closed")
self.assertRaises(frappe.InvalidStatusError, po.save)
def test_update_items_cannot_raise_qty_against_closed_blanket_order(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
po = make_purchase_order_against(bo, qty=10)
po.submit()
bo.update_status("Closed")
row = po.items[0]
self.assertRaises(frappe.InvalidStatusError, update_purchase_order_row_qty, po, row, 20)
update_purchase_order_row_qty(po, row, 5)
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
def test_closing_every_row_closes_the_blanket_order(self):
bo = make_blanket_order(blanket_order_type="Selling")
row = bo.items[0].name
update_closed_status("Blanket Order", bo.name, [row], 1)
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Closed")
bo.reload()
self.assertRaises(frappe.ValidationError, bo.update_status, "Submitted")
update_closed_status("Blanket Order", bo.name, [row], 0)
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Submitted")
def test_fully_ordered_row_cannot_be_closed(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=10)
make_purchase_order_against(bo, qty=10).submit()
self.assertRaises(
frappe.ValidationError, update_closed_status, "Blanket Order", bo.name, [bo.items[0].name], 1
)
def test_closed_row_is_skipped_when_ordering(self):
bo = make_two_row_purchasing_blanket_order()
po = make_purchase_order_against(bo, qty=10)
update_closed_status("Blanket Order", bo.name, [bo.items[0].name], 1)
frappe.flags.args.doctype = "Purchase Order"
self.assertEqual([row.item_code for row in make_order(bo.name).items], [bo.items[1].item_code])
self.assertRaises(frappe.InvalidStatusError, po.save)
for row, is_listed in ((bo.items[0], False), (bo.items[1], True)):
filters = {"company": bo.company, "blanket_order_type": "Purchasing", "item": row.item_code}
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertEqual(bo.name in [order[0] for order in orders], is_listed)
details = get_blanket_order_details(
{
"blanket_order": bo.name,
"company": bo.company,
"currency": bo.currency,
"supplier": bo.supplier,
"doctype": "Purchase Order",
"item_code": row.item_code,
"transaction_date": today(),
}
)
self.assertEqual(bool(details), is_listed)
def test_update_items_cannot_raise_qty_on_closed_row(self):
bo = make_two_row_purchasing_blanket_order()
po = make_purchase_order_against(bo, qty=10)
po.submit()
update_closed_status("Blanket Order", bo.name, [bo.items[0].name], 1)
row = po.items[0]
self.assertRaises(frappe.InvalidStatusError, update_purchase_order_row_qty, po, row, 20)
update_purchase_order_row_qty(po, row, 5)
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
def test_update_items_cannot_raise_qty_after_blanket_order_expires(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
po = make_purchase_order_against(bo, qty=10)
po.submit()
bo.db_set("to_date", add_days(po.transaction_date, -1))
row = po.items[0]
self.assertRaisesRegex(
frappe.ValidationError, "expired on", update_purchase_order_row_qty, po, row, 20
)
update_purchase_order_row_qty(po, row, 5)
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
def test_expired_blanket_order_cannot_be_ordered_against(self):
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
bo.db_set("to_date", today())
frappe.flags.args.doctype = "Purchase Order"
po = make_order(bo.name)
po.currency = get_company_currency(po.company)
po.transaction_date = add_days(today(), 1)
po.schedule_date = po.transaction_date
self.assertRaisesRegex(frappe.ValidationError, "expired on", po.save)
po.transaction_date = today()
po.save()
bo.db_set("to_date", add_days(today(), -1))
self.assertRaisesRegex(frappe.ValidationError, "expired on", make_order, bo.name)
filters = {
"company": bo.company,
"blanket_order_type": "Purchasing",
"item": bo.items[0].item_code,
"transaction_date": today(),
}
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertNotIn(bo.name, [order[0] for order in orders])
def test_party_item_code(self):
item_doc = make_item("_Test Item 1 for Blanket Order")
item_code = item_doc.name
@@ -438,6 +602,38 @@ def make_blanket_order(**args):
return bo
def make_purchase_order_against(blanket_order, qty):
frappe.flags.args.doctype = "Purchase Order"
po = make_order(blanket_order.name)
po.currency = get_company_currency(po.company)
po.schedule_date = today()
po.items[0].qty = qty
return po
def make_two_row_purchasing_blanket_order():
second_item = make_item("_Test Blanket Order Second Item", {"is_stock_item": 1}).name
bo = new_blanket_order(blanket_order_type="Purchasing")
bo.append("items", {"item_code": "_Test Item", "qty": 100, "rate": 100})
bo.append("items", {"item_code": second_item, "qty": 100, "rate": 100})
bo.insert()
bo.submit()
return bo
def update_purchase_order_row_qty(po, row, qty):
payload = {
"docname": row.name,
"item_code": row.item_code,
"qty": qty,
"rate": row.rate,
"uom": row.uom,
"conversion_factor": row.conversion_factor,
"schedule_date": str(row.schedule_date),
}
update_child_qty_rate("Purchase Order", json.dumps([payload]), po.name)
def make_priced_blanket_order(
blanket_order_type="Selling",
company="_Test Company",

View File

@@ -10,11 +10,13 @@
"party_item_code",
"column_break_3",
"qty",
"stock_uom",
"price_list_rate",
"base_price_list_rate",
"rate",
"base_rate",
"ordered_qty",
"closed",
"section_break_7",
"terms_and_conditions"
],
@@ -44,6 +46,16 @@
"in_list_view": 1,
"label": "Quantity"
},
{
"columns": 1,
"fetch_from": "item_code.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "price_list_rate",
"fieldtype": "Currency",
@@ -85,6 +97,15 @@
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "closed",
"fieldtype": "Check",
"label": "Closed",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
@@ -103,7 +124,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2026-09-23 21:30:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order Item",

View File

@@ -16,6 +16,7 @@ class BlanketOrderItem(Document):
base_price_list_rate: DF.Currency
base_rate: DF.Currency
closed: DF.Check
item_code: DF.Link
item_name: DF.Data | None
ordered_qty: DF.Float
@@ -26,6 +27,7 @@ class BlanketOrderItem(Document):
price_list_rate: DF.Currency
qty: DF.Float
rate: DF.Currency
stock_uom: DF.Link | None
terms_and_conditions: DF.Text | None
# end: auto-generated types

View File

@@ -728,24 +728,47 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
this.frm.events.update_cost(this.frm);
}
}
hour_rate(doc) {
erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_total(doc);
}
time_in_mins(doc) {
this.hour_rate(doc);
}
bom_no(doc, cdt, cdn) {
get_bom_material_detail(doc, cdt, cdn, false);
}
is_default(doc) {
if (doc.is_default) this.frm.set_value("is_active", 1);
}
qty(doc) {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
}
rate(doc, cdt, cdn) {
let d = locals[cdt][cdn];
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, false);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
}
}
validate(doc) {
erpnext.bom.update_cost(doc);
}
};
extend_cscript(cur_frm.cscript, new erpnext.bom.BomController({ frm: cur_frm }));
cur_frm.cscript.hour_rate = function (doc) {
erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_total(doc);
};
cur_frm.cscript.time_in_mins = cur_frm.cscript.hour_rate;
cur_frm.cscript.bom_no = function (doc, cdt, cdn) {
get_bom_material_detail(doc, cdt, cdn, false);
};
cur_frm.cscript.is_default = function (doc) {
if (doc.is_default) cur_frm.set_value("is_active", 1);
};
frappe.ui.form.set_controller("BOM", erpnext.bom.BomController);
var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
if (!doc.company) {
@@ -790,23 +813,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
}
};
cur_frm.cscript.qty = function (doc) {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
};
cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, false);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
}
};
erpnext.bom.update_cost = function (doc) {
erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_rm_cost(doc);
@@ -864,8 +870,8 @@ erpnext.bom.calculate_rm_cost = function (doc) {
total_rm_cost += amount;
base_total_rm_cost += base_amount;
}
cur_frm.set_value("raw_material_cost", total_rm_cost);
cur_frm.set_value("base_raw_material_cost", base_total_rm_cost);
frappe.model.set_value(doc.doctype, doc.name, "raw_material_cost", total_rm_cost);
frappe.model.set_value(doc.doctype, doc.name, "base_raw_material_cost", base_total_rm_cost);
};
// Calculate Total Cost
@@ -874,12 +880,8 @@ erpnext.bom.calculate_total = function (doc) {
var base_total_cost =
flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_secondary_items_cost);
cur_frm.set_value("total_cost", total_cost);
cur_frm.set_value("base_total_cost", base_total_cost);
};
cur_frm.cscript.validate = function (doc) {
erpnext.bom.update_cost(doc);
frappe.model.set_value(doc.doctype, doc.name, "total_cost", total_cost);
frappe.model.set_value(doc.doctype, doc.name, "base_total_cost", base_total_cost);
};
frappe.ui.form.on("BOM Operation", "operation", function (frm, cdt, cdn) {

View File

@@ -273,4 +273,4 @@ erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionC
}
};
extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({ frm: cur_frm }));
frappe.ui.form.set_controller("BOM Creator", erpnext.bom.BomConfigurator);

View File

@@ -12,13 +12,14 @@
"job_card_dashboard",
"section_break_fsba",
"work_order",
"operation",
"for_quantity",
"workstation",
"column_break_uqjq",
"stock_uom",
"production_item",
"bom_no",
"column_break_qrpg",
"for_quantity",
"column_break_yecz",
"stock_uom",
"employee",
"section_break_oisd",
"company",
"naming_series",
@@ -26,30 +27,27 @@
"posting_date",
"semi_finished_good__finished_good_section",
"finished_good",
"column_break_mcnb",
"semi_fg_bom",
"column_break_mcnb",
"batch_split",
"weight_per_piece",
"production_section",
"workstation_type",
"source_warehouse",
"column_break_12",
"skip_material_transfer",
"backflush_from_wip_warehouse",
"track_semi_finished_goods",
"wip_warehouse",
"target_warehouse",
"section_break_folk",
"pending_qty",
"column_break_cyjw",
"process_loss_qty",
"total_completed_qty",
"section_break_wpjf",
"transferred_qty",
"column_break_lgte",
"manufactured_qty",
"production_section",
"operation",
"source_warehouse",
"wip_warehouse",
"skip_material_transfer",
"backflush_from_wip_warehouse",
"column_break_12",
"workstation_type",
"workstation",
"target_warehouse",
"employee",
"transferred_qty",
"raw_material_tab",
"section_break_8",
"items",
"quality_inspection_section",
@@ -64,7 +62,6 @@
"expected_end_date",
"section_break_rzeo",
"scheduled_time_logs",
"timing_detail",
"section_break_13",
"actual_start_date",
"total_time_in_mins",
@@ -87,7 +84,6 @@
"requested_qty",
"is_paused",
"is_subcontracted",
"track_semi_finished_goods",
"column_break_20",
"project",
"remarks",
@@ -165,7 +161,7 @@
"fieldtype": "Float",
"in_list_view": 1,
"in_preview": 1,
"label": "Qty To Manufacture"
"label": "Qty to Manufacture"
},
{
"fieldname": "stock_uom",
@@ -182,11 +178,6 @@
"mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)",
"options": "Warehouse"
},
{
"fieldname": "timing_detail",
"fieldtype": "Tab Break",
"label": "Actual Time"
},
{
"allow_bulk_edit": 1,
"allow_on_submit": 1,
@@ -198,7 +189,8 @@
{
"fieldname": "section_break_13",
"fieldtype": "Section Break",
"hide_border": 1
"hide_border": 1,
"label": "Actual Time"
},
{
"allow_on_submit": 1,
@@ -207,7 +199,7 @@
"fieldname": "total_completed_qty",
"fieldtype": "Float",
"in_preview": 1,
"label": "Total Completed Qty",
"label": "Total Completed qty",
"non_negative": 1,
"read_only": 1
},
@@ -223,8 +215,7 @@
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break",
"label": "Raw Materials"
"fieldtype": "Section Break"
},
{
"fieldname": "items",
@@ -306,7 +297,7 @@
{
"fieldname": "production_section",
"fieldtype": "Section Break",
"label": "Operation & Materials"
"label": "Warehouse"
},
{
"fieldname": "column_break_12",
@@ -462,7 +453,7 @@
"depends_on": "process_loss_qty",
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"label": "Process Loss qty",
"read_only": 1
},
{
@@ -474,6 +465,7 @@
{
"fieldname": "scheduled_time_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Scheduled Time"
},
{
@@ -558,6 +550,7 @@
"default": "0",
"fieldname": "batch_split",
"fieldtype": "Check",
"hidden": 1,
"label": "Batch Split",
"read_only": 1
},
@@ -573,7 +566,7 @@
"depends_on": "eval:!doc.is_corrective_job_card",
"fieldname": "is_subcontracted",
"fieldtype": "Check",
"label": " Is Subcontracted",
"label": "Is Subcontracted",
"read_only": 1
},
{
@@ -591,7 +584,7 @@
{
"fieldname": "scheduled_time_tab",
"fieldtype": "Tab Break",
"label": "Scheduled Time"
"label": "Time logs"
},
{
"depends_on": "finished_good",
@@ -612,12 +605,13 @@
"depends_on": "eval:doc.finished_good && doc.skip_material_transfer === 1",
"fieldname": "backflush_from_wip_warehouse",
"fieldtype": "Check",
"label": "Backflush Materials From WIP Warehouse"
"label": "Backflush Materials from WIP Warehouse"
},
{
"default": "0",
"fieldname": "is_paused",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Paused",
"read_only": 1
},
@@ -627,7 +621,9 @@
"fetch_from": "work_order.track_semi_finished_goods",
"fieldname": "track_semi_finished_goods",
"fieldtype": "Check",
"label": "Track Semi Finished Goods"
"hidden": 1,
"label": "Track Semi Finished Goods",
"read_only": 1
},
{
"fieldname": "section_break_jcmx",
@@ -664,26 +660,19 @@
{
"fieldname": "section_break_folk",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "column_break_cyjw",
"fieldtype": "Column Break"
"hide_border": 1,
"label": "Progress"
},
{
"allow_on_submit": 1,
"fieldname": "pending_qty",
"fieldtype": "Float",
"label": "Pending Qty"
"label": "Pending qty"
},
{
"fieldname": "section_break_wpjf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_lgte",
"fieldtype": "Column Break"
},
{
"fieldname": "job_card_dashboard",
"fieldtype": "HTML"
@@ -696,14 +685,6 @@
"fieldname": "column_break_uqjq",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_qrpg",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_yecz",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_smqo",
"fieldtype": "Section Break",
@@ -712,13 +693,18 @@
{
"fieldname": "section_break_fsba",
"fieldtype": "Section Break"
},
{
"fieldname": "raw_material_tab",
"fieldtype": "Tab Break",
"label": "Raw Materials"
}
],
"grid_page_length": 50,
"icon": "clipboard-list",
"is_submittable": 1,
"links": [],
"modified": "2026-08-28 12:00:00.000000",
"modified": "2026-09-23 16:35:26.917025",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -88,8 +88,8 @@ class JobCard(Document):
amended_from: DF.Link | None
backflush_from_wip_warehouse: DF.Check
barcode: DF.Barcode | None
batch_split: DF.Check
batch_no: DF.Link | None
batch_split: DF.Check
bom_no: DF.Link | None
company: DF.Link
employee: DF.TableMultiSelect[JobCardTimeLog]
@@ -145,9 +145,9 @@ class JobCard(Document):
time_required: DF.Float
total_completed_qty: DF.Float
total_time_in_mins: DF.Float
weight_per_piece: DF.Float
track_semi_finished_goods: DF.Check
transferred_qty: DF.Float
weight_per_piece: DF.Float
wip_warehouse: DF.Link | None
work_order: DF.Link
workstation: DF.Link

View File

@@ -9,10 +9,18 @@
"field_order": [
"naming_series",
"company",
"get_items_from",
"column_break1",
"status",
"posting_date",
"reserve_stock",
"other_details",
"total_planned_qty",
"total_produced_qty",
"column_break_32",
"warehouses",
"amended_from",
"demand_tab",
"get_items_from",
"filters",
"item_code",
"customer",
@@ -31,11 +39,13 @@
"material_request_detail",
"get_material_request",
"material_requests",
"finished_items_tab",
"select_items_to_manufacture_section",
"get_items",
"po_items",
"section_break_25",
"prod_plan_references",
"sub_assemblies_tab",
"section_break_24",
"sub_assembly_warehouse",
"column_break_igxl",
@@ -45,6 +55,7 @@
"get_sub_assembly_items",
"section_break_g4ip",
"sub_assembly_items",
"raw_material_tab",
"download_materials_request_plan_section_section",
"download_materials_required",
"material_request_planning",
@@ -59,14 +70,7 @@
"get_items_for_mr",
"transfer_materials",
"section_break_27",
"mr_items",
"other_details",
"total_planned_qty",
"total_produced_qty",
"column_break_32",
"status",
"warehouses",
"amended_from"
"mr_items"
],
"fields": [
{
@@ -105,7 +109,6 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.__islocal",
"depends_on": "eval: doc.get_items_from",
"fieldname": "filters",
"fieldtype": "Section Break",
@@ -157,7 +160,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.__islocal",
"collapsible_depends_on": "eval: doc.get_items_from === \"Sales Order\"",
"depends_on": "eval: doc.get_items_from == \"Sales Order\"",
"fieldname": "sales_orders_detail",
"fieldtype": "Section Break",
@@ -177,7 +180,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.__islocal",
"collapsible_depends_on": "eval: doc.get_items_from === \"Material Request\"",
"depends_on": "eval: doc.get_items_from == \"Material Request\"",
"fieldname": "material_request_detail",
"fieldtype": "Section Break",
@@ -235,7 +238,7 @@
},
{
"default": "1",
"description": "If enabled, formula for <b>Required Qty</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"description": "If enabled, formula for <b>Required Qty</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\" rel=\"noopener noreferrer\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"fieldname": "ignore_existing_ordered_qty",
"fieldtype": "Check",
"label": "Consider Projected Qty in Calculation (RM)"
@@ -364,7 +367,7 @@
"fieldname": "prod_plan_references",
"fieldtype": "Table",
"hidden": 1,
"label": "Production Plan Item Reference",
"label": "Sales Order Reference",
"options": "Production Plan Item Reference"
},
{
@@ -417,7 +420,7 @@
},
{
"default": "1",
"description": "If enabled, formula for <b>Qty to Order</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"description": "If enabled, formula for <b>Qty to Order</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\" rel=\"noopener noreferrer\">Projected Qty</a>. <br> This helps avoid over-ordering.",
"fieldname": "skip_available_sub_assembly_item",
"fieldtype": "Check",
"label": "Consider Projected Qty in Calculation"
@@ -454,6 +457,26 @@
"fieldname": "no_of_shifts",
"fieldtype": "Int",
"label": "No of Shifts"
},
{
"fieldname": "demand_tab",
"fieldtype": "Tab Break",
"label": "Demand"
},
{
"fieldname": "finished_items_tab",
"fieldtype": "Tab Break",
"label": "Finished Items"
},
{
"fieldname": "sub_assemblies_tab",
"fieldtype": "Tab Break",
"label": "Sub Assemblies"
},
{
"fieldname": "raw_material_tab",
"fieldtype": "Tab Break",
"label": "Raw Material"
}
],
"grid_page_length": 50,
@@ -461,7 +484,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 00:00:00.000000",
"modified": "2026-09-15 13:02:23.654005",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan",

View File

@@ -101,6 +101,7 @@ class ProductionPlan(Document):
material_requests: DF.Table[ProductionPlanMaterialRequest]
mr_items: DF.Table[MaterialRequestPlanItem]
naming_series: DF.Literal["MFG-PP-.YYYY.-"]
no_of_shifts: DF.Int
po_items: DF.Table[ProductionPlanItem]
posting_date: DF.Date
prod_plan_references: DF.Table[ProductionPlanItemReference]

View File

@@ -1623,6 +1623,22 @@ class TestWorkOrder(ERPNextTestSuite):
available_by_attribution = {row.original_item: row.qty for row in return_entry.items}
self.assertEqual(available_by_attribution, {None: 1.0, "_Test Item Home Desktop 100": 2.0})
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_return_excludes_material_consumption_entry(self):
rm_item = make_item(properties={"is_stock_item": 1}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=1)
test_stock_entry.make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, basic_rate=100)
work_order = make_wo_order_test_record(
production_item=fg_item, bom_no=bom.name, qty=10, source_warehouse="Stores - _TC"
)
frappe.get_doc(make_stock_entry(work_order.name, "Material Transfer for Manufacture", 10)).submit()
frappe.get_doc(make_stock_entry(work_order.name, "Material Consumption for Manufacture", 6)).submit()
return_entry = make_stock_return_entry(work_order.name)
self.assertEqual([row.qty for row in return_entry.items], [4])
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must

View File

@@ -179,7 +179,7 @@ frappe.ui.form.on("Work Order", {
},
refresh: function (frm) {
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
erpnext.work_order.set_custom_buttons(frm);
frm.set_intro("");

View File

@@ -1,22 +0,0 @@
// rename this file from _test_[name] to test_[name] to activate
// and remove above this line
QUnit.test("test: Workstation", function (assert) {
let done = assert.async();
// number of asserts
assert.expect(1);
frappe.run_serially("Workstation", [
// insert a new Workstation
() =>
frappe.tests.make([
// values to be set
{ key: "value" },
]),
() => {
assert.equal(cur_frm.doc.key, "value");
},
() => done(),
]);
});

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Workstation", {
method: "erpnext.manufacturing.doctype.workstation.workstation.get_default_holiday_list",
callback: function (r) {
if (!r.exe && r.message) {
cur_frm.set_value("holiday_list", r.message);
frm.set_value("holiday_list", r.message);
}
},
});

View File

@@ -253,6 +253,7 @@ def submit_quality_inspection(job_card: str, readings: str | None = None):
qi.item_code = jc.finished_good or jc.production_item
qi.bom_no = jc.bom_no
qi.quality_inspection_template = template
qi.sample_size = 1
qi.inspected_by = frappe.session.user
qi.get_item_specification_details() # load readings from the template

View File

@@ -1224,6 +1224,13 @@ def get_item_details(item_code, company):
if default_data:
data.update(default_data)
if not data.get("default_supplier"):
# fall back to the item's Item Group default supplier (mirrors BuyingController)
item_group = frappe.db.get_value("Item", item_code, "item_group")
data.default_supplier = frappe.db.get_value(
"Item Default", {"parent": item_group, "company": company}, "default_supplier"
)
return data
@@ -1333,6 +1340,7 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
work_orders = []
covered_rows = 0
missing_bom = []
missing_supplier = []
for row in selected_rows:
row = frappe._dict(row)
# what is left to order once stock and the orders already placed are counted. rounding
@@ -1343,7 +1351,10 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
continue
if row.type_of_material == "Purchase":
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
if row.default_supplier:
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
elif row.item_code not in missing_supplier:
missing_supplier.append(row.item_code)
if row.type_of_material == "Manufacture":
if row.bom_no:
@@ -1354,6 +1365,9 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
if missing_bom:
frappe.throw(_("Default BOM for {0} not found").format(", ".join(missing_bom)))
if missing_supplier:
frappe.throw(_("Default Supplier for {0} not found").format(", ".join(missing_supplier)))
if not purchase_orders and not work_orders:
frappe.msgprint(
_("Nothing to order, the selected rows are already covered by stock or existing orders")

View File

@@ -280,6 +280,52 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
def test_make_order_rejects_purchase_item_without_supplier(self):
plan = make_mps_item(
self,
{
"is_stock_item": 1,
"is_purchase_item": 1,
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
},
)
self.assertEqual(plan.row.type_of_material, "Purchase")
self.assertFalse(plan.row.default_supplier)
with self.assertRaises(frappe.ValidationError) as ctx:
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
self.assertIn("Default Supplier", str(ctx.exception))
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
def test_make_order_falls_back_to_item_group_default_supplier(self):
group = "_Test MRP Item Group With Supplier"
if not frappe.db.exists("Item Group", group):
frappe.get_doc(
{
"doctype": "Item Group",
"item_group_name": group,
"parent_item_group": "All Item Groups",
"item_group_defaults": [{"company": COMPANY, "default_supplier": SUPPLIER}],
}
).insert()
plan = make_mps_item(
self,
{
"is_stock_item": 1,
"is_purchase_item": 1,
"item_group": group,
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
},
)
self.assertEqual(plan.row.type_of_material, "Purchase")
self.assertEqual(plan.row.default_supplier, SUPPLIER)
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
self.assertEqual(get_created_order(plan.mps, "Purchase Order").supplier, SUPPLIER)
def test_make_order_uses_the_bom_passed_on_the_row(self):
plan = make_mps_item(
self,

View File

@@ -172,7 +172,8 @@ def get_column(filters):
},
{
"label": _("Document Type"),
"fieldtype": "Data",
"fieldtype": "Link",
"options": "DocType",
"fieldname": "document_type",
"width": 120,
},

View File

@@ -97,7 +97,13 @@ def get_columns(filters):
"fieldtype": "Data",
"width": 110,
},
{"label": _("Document Type"), "fieldname": "reference_type", "fieldtype": "Data", "width": 90},
{
"label": _("Document Type"),
"fieldname": "reference_type",
"fieldtype": "Link",
"options": "DocType",
"width": 90,
},
{
"label": _("Document Name"),
"fieldname": "reference_name",

View File

@@ -523,3 +523,7 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.v16_0.recalculate_holiday_list_totals
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
erpnext.patches.v16_0.rename_component_cost_valuation_type
erpnext.patches.v16_0.enable_serial_no_wise_valuation
erpnext.patches.v16_0.add_voucher_index_to_repost_item_valuation
erpnext.patches.v16_0.set_blanket_order_status
erpnext.patches.v16_0.set_blanket_order_item_stock_uom

View File

@@ -0,0 +1,6 @@
import frappe
def execute():
# on_doctype_update only runs when the DocType itself is re-synced, so existing sites need this.
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")

View File

@@ -0,0 +1,11 @@
import frappe
def execute():
item = frappe.qb.DocType("Item")
(
frappe.qb.update(item)
.set(item.use_serial_no_wise_valuation, 1)
.where((item.has_serial_no == 1) & (item.use_serial_no_wise_valuation == 0))
).run()

View File

@@ -0,0 +1,12 @@
import frappe
def execute():
item_codes = set(
frappe.get_all("Blanket Order Item", filters={"stock_uom": ("is", "not set")}, pluck="item_code")
)
for item_code in item_codes:
stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
frappe.db.set_value(
"Blanket Order Item", {"item_code": item_code}, "stock_uom", stock_uom, update_modified=False
)

View File

@@ -0,0 +1,8 @@
import frappe
def execute():
for docstatus, status in ((0, "Draft"), (1, "Submitted"), (2, "Cancelled")):
frappe.db.set_value(
"Blanket Order", {"docstatus": docstatus}, "status", status, update_modified=False
)

View File

@@ -1 +1,5 @@
cur_frm.add_fetch("employee", "employee_name", "employee_name");
frappe.ui.form.on("Activity Cost", {
setup: function (frm) {
frm.add_fetch("employee", "employee_name", "employee_name");
},
});

View File

@@ -1,103 +0,0 @@
QUnit.test("test project", function (assert) {
assert.expect(6);
let done = assert.async();
var task_title = ["Documentation", "Implementation", "Testing"];
// To create a timesheet with different tasks and costs
let timesheet = (title, start_time, end_time, bill_rate, cost_rate) => {
return frappe.run_serially([
() => frappe.db.get_value("Task", { subject: title }, "name"),
(task) => {
// Creating timesheet for a project
return frappe.tests.make("Timesheet", [
{
time_logs: [
[
{ activity_type: "Communication" },
{ from_time: start_time },
{ to_time: end_time },
{ hours: 2 },
{ project: "Test App" },
{ task: task.name },
{ billable: "1" },
{ billing_rate: bill_rate },
{ costing_rate: cost_rate },
],
],
},
]);
},
// To check if a correct billable and costing amount is calculated for every task
() => {
if (title === "Documentation") {
assert.ok(
cur_frm.get_field("total_billable_amount").get_value() == 20,
"Billable amount for Documentation task is correctly calculated"
);
assert.ok(
cur_frm.get_field("total_costing_amount").get_value() == 16,
"Costing amount for Documentation task is correctly calculated"
);
}
if (title === "Implementation") {
assert.ok(
cur_frm.get_field("total_billable_amount").get_value() == 40,
"Billable amount for Implementation task is correctly calculated"
);
assert.ok(
cur_frm.get_field("total_costing_amount").get_value() == 32,
"Costing amount for Implementation task is correctly calculated"
);
}
if (title === "Testing") {
assert.ok(
cur_frm.get_field("total_billable_amount").get_value() == 60,
"Billable amount for Testing task correctly calculated"
);
assert.ok(
cur_frm.get_field("total_costing_amount").get_value() == 50,
"Costing amount for Testing task is correctly calculated"
);
}
},
]);
};
frappe.run_serially([
() => {
// Creating project with task
return frappe.tests.make("Project", [
{ project_name: "Test App" },
{ expected_start_date: "2017-07-22" },
{ expected_end_date: "2017-09-22" },
{ estimated_costing: "10,000.00" },
{
tasks: [
[
{ title: "Documentation" },
{ start_date: "2017-07-24" },
{ end_date: "2017-07-31" },
{ description: "To make a proper documentation defining requirements etc" },
],
[
{ title: "Implementation" },
{ start_date: "2017-08-01" },
{ end_date: "2017-08-01" },
{ description: "Writing algorithms and to code the functionalities" },
],
[
{ title: "Testing" },
{ start_date: "2017-08-01" },
{ end_date: "2017-08-15" },
{ description: "To make the test cases and test the functionalities" },
],
],
},
]);
},
// Creating Timesheet with different tasks
() => timesheet(task_title[0], "2017-07-24 13:00:00", "2017-07-24 13:00:00", 10, 8),
() => timesheet(task_title[1], "2017-07-25 13:00:00", "2017-07-25 15:00:00", 20, 16),
() => timesheet(task_title[2], "2017-07-26 13:00:00", "2017-07-26 15:00:00", 30, 25),
() => done(),
]);
});

View File

@@ -1,4 +1,49 @@
frappe.provide("erpnext.accounts.bank_reconciliation");
frappe.provide("erpnext.accounts.bank_reconciliation.voucher_types");
// other apps can register more "Create Voucher" types: { get_fields(dm), is_applicable(bank_transaction), create(dm, values, allow_edit) }
erpnext.accounts.bank_reconciliation.voucher_types = {
"Payment Entry": {
create(dialog_manager, values, allow_edit) {
return frappe.xcall(
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
{
bank_transaction_name: dialog_manager.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
project: values.project,
cost_center: values.cost_center,
allow_edit: allow_edit,
company_bank_account: values?.bank_account || dialog_manager?.bank_account,
}
);
},
},
"Journal Entry": {
create(dialog_manager, values, allow_edit) {
return frappe.xcall(
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
{
bank_transaction_name: dialog_manager.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
entry_type: values.journal_entry_type,
second_account: values.second_account,
allow_edit: allow_edit,
}
);
},
},
...erpnext.accounts.bank_reconciliation.voucher_types,
};
erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
constructor(
@@ -49,6 +94,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
this.bank_transaction = r.message;
r.message.payment_entry = 1;
r.message.journal_entry = 1;
this.set_document_type_options();
this.dialog.set_values(r.message);
this.copy_data_to_voucher();
this.dialog.show();
@@ -57,6 +103,29 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
});
}
get_voucher_types() {
return erpnext.accounts.bank_reconciliation.voucher_types;
}
get_document_types() {
return Object.entries(this.get_voucher_types())
.filter(
([, voucher_type]) =>
!this.bank_transaction ||
!voucher_type.is_applicable ||
voucher_type.is_applicable(this.bank_transaction)
)
.map(([document_type]) => document_type);
}
set_document_type_options() {
const document_types = this.get_document_types();
this.dialog.set_df_property("document_type", "options", document_types.join("\n"));
if (!document_types.includes(this.dialog.get_value("document_type"))) {
this.dialog.set_value("document_type", document_types[0]);
}
}
copy_data_to_voucher() {
let copied = {
reference_number: this.bank_transaction.reference_number || this.bank_transaction.description,
@@ -186,7 +255,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
label: __("Document Type"),
fieldname: "document_type",
fieldtype: "Select",
options: `Payment Entry\nJournal Entry`,
options: this.get_document_types().join("\n"),
default: "Payment Entry",
depends_on: "eval:doc.action=='Create Voucher'",
},
@@ -398,6 +467,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
};
},
},
...this.get_additional_voucher_fields(),
{
fieldtype: "Section Break",
fieldname: "details_section",
@@ -459,6 +529,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
];
}
get_additional_voucher_fields() {
return Object.values(this.get_voucher_types()).flatMap((voucher_type) =>
voucher_type.get_fields ? voucher_type.get_fields(this) : []
);
}
get_selected_attributes() {
let selected_attributes = [];
this.dialog.$wrapper.find(".checkbox input").each((i, col) => {
@@ -477,10 +553,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
reconciliation_dialog_primary_action(values) {
if (values.action == "Match Against Voucher") this.match(values);
if (values.action == "Create Voucher" && values.document_type == "Payment Entry")
this.add_payment_entry(values);
if (values.action == "Create Voucher" && values.document_type == "Journal Entry")
this.add_journal_entry(values);
else if (values.action == "Create Voucher") this.create_voucher(values);
else if (values.action == "Update Bank Transaction") this.update_transaction(values);
}
@@ -513,54 +586,25 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
});
}
add_payment_entry(values) {
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
args: {
bank_transaction_name: this.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
project: values.project,
cost_center: values.cost_center,
company_bank_account: values?.bank_account || this?.bank_account,
},
callback: (response) => {
const alert_string = __("Bank Transaction {0} added as Payment Entry", [
this.bank_transaction.name,
]);
frappe.show_alert(alert_string);
this.update_dt_cards(response.message);
this.dialog.hide();
},
});
}
create_voucher(values, allow_edit = false) {
const voucher_type = this.get_voucher_types()[values.document_type];
if (!voucher_type) {
frappe.throw(__("Cannot create {0} from a Bank Transaction", [values.document_type]));
}
add_journal_entry(values) {
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
args: {
bank_transaction_name: this.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
entry_type: values.journal_entry_type,
second_account: values.second_account,
},
callback: (response) => {
const alert_string = __("Bank Transaction {0} added as Journal Entry", [
this.bank_transaction.name,
]);
frappe.show_alert(alert_string);
this.update_dt_cards(response.message);
this.dialog.hide();
},
return voucher_type.create(this, values, allow_edit).then((message) => {
if (allow_edit) {
const doc = frappe.model.sync(message);
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
return;
}
frappe.show_alert(
__("Bank Transaction {0} added as {1}", [this.bank_transaction.name, values.document_type])
);
this.update_dt_cards(message);
this.dialog.hide();
});
}
@@ -584,50 +628,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
edit_in_full_page() {
const values = this.dialog.get_values(true);
if (values.document_type == "Payment Entry") {
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
args: {
bank_transaction_name: this.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
project: values.project,
cost_center: values.cost_center,
allow_edit: true,
company_bank_account: values?.bank_account || this?.bank_account,
},
callback: (r) => {
const doc = frappe.model.sync(r.message);
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
} else {
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
args: {
bank_transaction_name: this.bank_transaction.name,
reference_number: values.reference_number,
reference_date: values.reference_date,
party_type: values.party_type,
party: values.party,
posting_date: values.posting_date,
mode_of_payment: values.mode_of_payment,
entry_type: values.journal_entry_type,
second_account: values.second_account,
allow_edit: true,
},
callback: (r) => {
var doc = frappe.model.sync(r.message);
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
}
return this.create_voucher(values, true);
}
};
@@ -639,7 +640,7 @@ const track_voucher = (doctype, docname, bank_transaction_name) => {
pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name);
};
for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) {
for (const voucher_doctype of Object.keys(erpnext.accounts.bank_reconciliation.voucher_types)) {
frappe.ui.form.on(voucher_doctype, {
before_save(frm) {
frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name);

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