Compare commits

...

74 Commits

Author SHA1 Message Date
Sumit Jain
04bea0733b feat: implement auto-demotion of other primary contacts in Supplier and Customer import providers 2026-08-09 21:53:14 +05:30
Sumit Jain
179c7c0743 fix: update primary address handling in Supplier and Customer import providers 2026-08-09 21:33:39 +05:30
Sumit Jain
fafb0b1505 Merge branch 'develop' into party-import-tool-integrated-with-data-import-tool 2026-08-09 20:51:08 +05:30
Sumit Jain
3a69ada5ff refactor: simplify contact and address creation logic in import providers 2026-08-09 20:44:23 +05:30
Sumit Jain
62be241701 feat: implement Supplier import provider for data import functionality 2026-08-09 20:13:57 +05:30
Sumit Jain
cfbbf2f165 feat: add Customer import providers for data import functionality 2026-08-09 20:13:51 +05:30
Mihir Kandoi
fe09f45ca7 Merge pull request #57895 from mihir-kandoi/fix-track-sfg-finished-good
fix: track semi finished goods production flow
2026-08-09 20:11:05 +05:30
Mihir Kandoi
0bb322f4d1 Merge pull request #57560 from nishkagosalia/gh-56632
fix: handling negative grand total
2026-08-09 16:45:13 +05:30
Mihir Kandoi
bf13284137 Merge pull request #57917 from mihir-kandoi/fix/italy-customer-name-fields
fix(regional): rename Italy's duplicate Customer name fields
2026-08-09 16:44:54 +05:30
Mihir Kandoi
4089f138f2 fix(controllers): correct negative rate settings link 2026-08-09 16:29:28 +05:30
Mihir Kandoi
110d0a38a6 fix(regional): rename Italy's duplicate Customer name fields
The Italy regional setup created Custom Fields first_name/last_name on
Customer. Since #46281 added standard quick-entry fields with the same
names, every Italian site carries duplicate field definitions:

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

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

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

Calling notify_update() on the reference publishes the standard
doc_update event, so an open, unedited form silently reloads and syncs
its timestamp. get_lazy_doc skips child table loading since
notify_update only needs the parent row.
2026-08-09 15:45:07 +05:30
MochaMind
40d59f9a15 chore: update POT file (#57915) 2026-08-09 10:02:29 +00:00
Mihir Kandoi
4a31772f3d Merge pull request #57914 from aerele/fix/outward-bundle-sign
fix: negate stock value difference for outward transfer bundles
2026-08-09 14:37:06 +05:30
pandiyan
eff29565ae fix: negate stock value difference for outward transfer bundles
make_bundle_for_material_transfer squares stock_value_difference for
outward rows instead of negating it. multiply by -1, matching the qty
negation on the line above.

no behaviour change: set_incoming_rate and calculate_qty_and_amount both
recompute the field from qty * incoming_rate before the bundle is saved.
2026-08-09 13:20:48 +05:30
Mihir Kandoi
691c341edd Merge pull request #57911 from aerele/fix/sales-team-allocated-percentage-float
fix: tolerate floating-point drift in sales team allocated percentage
2026-08-09 12:28:56 +05:30
pandiyan
4afba94d1c test: sales team allocation totalling 100 in floating point
covers the case where the percentages are correct but the accumulated
sum is 100.00000000000001. two rows can never drift, since the second
reconstructs exactly as 100 - first, so the case needs three rows.
2026-08-09 11:25:24 +05:30
pandiyan
f7b2775829 fix: tolerate floating-point drift in sales team allocated percentage
the total of allocated_percentage was compared to 100 with exact float
equality, so a correct allocation could be rejected when the sum drifts
in binary floating point (10.0 + 58.02 + 31.98 -> 100.00000000000001).

round the total to the field precision before comparing, in both
SellingController.calculate_contribution and Customer.validate.
2026-08-09 11:25:12 +05:30
pandiyan
24de81f9fa test: work order process loss for semi finished goods
Cover both shapes: a single operation that books the loss itself, and a
chain where an earlier operation books it and the final operation loses
nothing, so the sum over the operations is the only correct source.
2026-08-09 09:45:49 +05:30
pandiyan
0eb61c9fac fix: roll up process loss to the work order for semi finished goods
update_work_order_qty() returns early when track_semi_finished_goods is
enabled, so set_process_loss_qty() never ran and Work Order.process_loss_qty
stayed at zero even though the job cards and the work order operations had
booked the loss. The work order also never reached the Completed status,
since that needs produced_qty + process_loss_qty to cover the ordered qty.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

When the minimum binds and the nearest-rounded value dips below it,
quantize to the smallest representable purchase-UOM quantity whose stock
equivalent meets the minimum, using Decimal grid-ceiling arithmetic.
110.232 converts to 50000.386: demand stays as planned and the overage
is order-unit granularity, the standard MRP lot-sizing outcome. Ordinary
conversions keep the historical round-to-nearest behavior.
2026-08-07 22:20:30 +05:30
Mihir Kandoi
594ff24dae Merge pull request #57879 from mihir-kandoi/remove-dead-mr-po-mapper
refactor: remove unused make_purchase_order_based_on_supplier
2026-08-07 21:03:07 +05:30
Shllokkk
67f4b7a483 Merge pull request #57880 from Shllokkk/guard-clear-reconciliation-patch
fix: guard reconciliation table deletes when tables are missing
2026-08-07 20:03:19 +05:30
Shllokkk
8a2b2a2b68 fix: guard reconciliation table deletes when tables are missing 2026-08-07 19:33:45 +05:30
Mihir Kandoi
8b2946ca6b refactor: remove unused make_purchase_order_based_on_supplier
Its only caller, Purchase Order's get_items_from_open_material_requests,
was deleted in 91e9867fb1 (refactor: Cleanup buying module forms). The
old dotted path was already broken by the move to mapper.py, so no
external caller can be using it either.
2026-08-07 18:42:00 +05:30
Mihir Kandoi
ca5a673409 fix: round computed conversion factors to field precision
The inverse (1 / value) and intermediate-UOM branches of
get_uom_conv_factor returned raw float quotients like
0.4535922921968971, bypassing the precision the docfields now declare.
Same for the client-side back-calculation from an edited stock qty.
Round both to the UOM Conversion Factor value precision.
2026-08-07 17:47:50 +05:30
Mihir Kandoi
69a35a12cb fix: declare precision 9 on all conversion_factor fields
The Float control parses values with the field precision, falling back
to the global float precision when the docfield declares none
(frappe ControlFloat.parse / get_precision). On a site with float
precision 2, a fetched UOM factor of 0.453592292 was written back to
the model as 0.45, silently corrupting every derived quantity by 0.8
percent. A ratio must not inherit display precision meant for
quantities, so declare the same precision 9 the UOM Conversion Factor
master already uses on every transaction-level conversion_factor
field.
2026-08-07 17:31:32 +05:30
Suhas Bharadwaj
e0b9351d49 fix: condition check with empty object for falsy case 2026-08-06 16:38:46 +05:30
nishkagosalia
136f92db04 fix: handling negative grand total 2026-07-29 14:31:33 +05:30
91 changed files with 4288 additions and 1129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.save()
self.assertEqual(po.items[1].qty, 1)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
self.assertRaises(frappe.ValidationError, po.save)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
po.save()
po.submit()
self.assertEqual(po.docstatus, 1)
self.assertTrue(po.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
self.assertRaises(frappe.ValidationError, po.save)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
@@ -320,6 +342,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db()
existing_ordered_qty = get_ordered_qty()
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
first_item_of_po = po.get("items")[0]
trans_item = json.dumps(
@@ -330,16 +353,62 @@ class TestPurchaseOrder(ERPNextTestSuite):
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": "_Test Item", "rate": 200, "qty": 7},
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
po.reload()
self.assertEqual(len(po.get("items")), 2)
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
self.assertEqual(po.status, "To Receive and Bill")
# ordered qty should increase on row addition
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
# ordered qty should increase on row addition, in the warehouse passed for the new row
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
self.assertEqual(
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
existing_ordered_qty_in_new_warehouse + 7,
)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
po = create_purchase_order(do_not_save=1)
po.save()
po.submit()
first_item_of_po = po.get("items")[0]
company_default = frappe.db.get_value("Company", po.company, "default_warehouse")
frappe.db.set_value("Company", po.company, "default_warehouse", None)
self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default)
def get_trans_items(item_code):
return json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": item_code, "rate": 200, "qty": 7},
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Purchase Order",
get_trans_items(stock_item),
po.name,
)
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
po.reload()
self.assertEqual(po.get("items")[-1].item_code, service_item)
self.assertFalse(po.get("items")[-1].warehouse)
def test_update_child_removing_item(self):
po = create_purchase_order(do_not_save=1)
@@ -724,6 +793,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
below_minimum.items[0].conversion_factor = 0.6
self.assertRaises(frappe.ValidationError, below_minimum.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
def test_uom_integer_check_tolerates_conversion_dust(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,238 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Data Import provider for Supplier.
Imports a Supplier together with Contact and Address records. Contact and Address are
separate doctypes linked through Dynamic Link rows; the provider exposes them as child
sections in import schema and creates linked records during row import.
"""
import frappe
from frappe import _
from frappe.core.doctype.data_import.import_provider import ImportProvider
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
from erpnext.selling.doctype.customer.mapper import parse_full_name
class SupplierImportProvider(ImportProvider):
def get_import_fields(self) -> dict:
return {
"fields": _doctype_docfields("Supplier"),
"child_tables": [
*_doctype_child_tables("Supplier"),
{
"fieldname": "contacts",
"label": _("Contact"),
"fields": _contact_docfields(),
},
{
"fieldname": "addresses",
"label": _("Address"),
"fields": _doctype_docfields("Address", prefer_plain_label=True),
},
],
}
def validate(self, import_file) -> list[dict]:
warnings = []
for payload in import_file.get_payloads_for_import():
doc = payload.doc
row = payload.rows[0].row_number if payload.rows else None
for contact in doc.get("contacts") or []:
# Keep parity with current Customer provider behavior: require at least one contact method.
if not (contact.get("email_id") or contact.get("mobile_no")):
missing = [
label
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
if not contact.get(field)
]
warnings.append(
{
"row": row,
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
}
)
for address in doc.get("addresses") or []:
if not address.get("address_line1"):
continue
missing = [
label
for field, label in (("city", _("City")), ("country", _("Country")))
if not address.get(field)
]
if missing:
warnings.append(
{
"row": row,
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
}
)
return warnings
def import_row(self, importer, doc):
contact_rows = doc.pop("contacts", None) or []
address_rows = doc.pop("addresses", None) or []
has_child_rows = bool(contact_rows or address_rows)
supplier, import_action = self._persist_supplier(importer, doc, has_child_rows)
self._create_contacts(supplier, contact_rows)
self._create_addresses(supplier, address_rows)
return supplier, import_action
def _persist_supplier(self, importer, doc, has_child_rows):
if importer.import_type == INSERT:
return importer.insert_record(doc), None
if importer.import_type == UPDATE:
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
return importer.upsert_record(doc)
def _create_contacts(self, supplier, rows):
primary = None
for row in rows:
row = dict(row)
email = row.pop("email_id", None)
mobile = row.pop("mobile_no", None)
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
first_name, last_name, company_name = self._resolve_contact_names(supplier, row)
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
if first_name:
contact_values["first_name"] = first_name
if last_name:
contact_values["last_name"] = last_name
if company_name:
contact_values["company_name"] = company_name
contact = frappe.get_doc(
{
"doctype": "Contact",
**contact_values,
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
}
)
if email:
contact.add_email(email, is_primary=True)
if mobile:
contact.add_phone(mobile, is_primary_mobile_no=True)
contact.insert()
# First created contact is the default primary; an explicit flag overrides.
if flagged or primary is None:
primary = contact
if primary:
# Contact has no cross-contact auto-demotion (unlike Address's
# validate_preferred_address), so explicitly demote any other primary Contact on
# this party first — otherwise get_default_contact may return a Contact other
# than supplier_primary_contact.
_demote_other_primary_contacts("Supplier", supplier.name, primary.name)
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
supplier.db_set("supplier_primary_contact", primary.name)
supplier.db_set("mobile_no", primary.mobile_no)
supplier.db_set("email_id", primary.email_id)
def _resolve_contact_names(self, supplier, row):
first_name = row.pop("first_name", None)
last_name = row.pop("last_name", None)
company_name = row.pop("company_name", None)
supplier_get = getattr(supplier, "get", None)
def get_supplier_value(fieldname):
if callable(supplier_get):
return supplier_get(fieldname)
return getattr(supplier, fieldname, None)
if supplier.supplier_type == "Individual":
first_name = first_name or get_supplier_value("first_name")
last_name = last_name or get_supplier_value("last_name")
if not first_name and supplier.supplier_name:
parsed_first, _, parsed_last = parse_full_name(supplier.supplier_name)
first_name = parsed_first
last_name = last_name or parsed_last
return first_name, last_name, company_name
def _create_addresses(self, supplier, rows):
from frappe.contacts.doctype.address.address import get_address_display
primary = None
for row in rows:
row = dict(row)
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
if not row.get("address_line1"):
continue
row["address_type"] = row.get("address_type") or "Billing"
row["address_title"] = row.get("address_title") or supplier.supplier_name
address = frappe.get_doc(
{
"doctype": "Address",
**{k: v for k, v in row.items() if v not in (None, "")},
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
}
)
address.insert()
# First created address is the default primary; an explicit flag overrides.
# (Must not key off the loop index — skipped rows would leave no primary.)
if flagged or primary is None:
primary = address
if primary:
# Save (not db.set_value) so Address.validate_preferred_address() clears any
# existing primary address on the party — a raw write would leave two flagged.
primary.is_primary_address = 1
primary.save()
supplier.db_set("supplier_primary_address", primary.name)
supplier.db_set("primary_address", get_address_display(primary.name))
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
linked = frappe.get_all(
"Dynamic Link",
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
pluck="parent",
)
for other in frappe.get_all(
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
):
if other != keep:
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
from frappe.model import display_fieldtypes, no_value_fields
fields = []
for df in frappe.get_meta(doctype).fields:
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
continue
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
continue
field_dict = df.as_dict()
if prefer_plain_label:
field_dict["prefer_plain_label"] = 1
fields.append(field_dict)
return fields
def _contact_docfields() -> list[dict]:
fields = _doctype_docfields("Contact", prefer_plain_label=True)
for field in fields:
if field.get("fieldname") == "email_id":
field["import_labels"] = ["Email ID"]
return fields
def _doctype_child_tables(doctype: str) -> list[dict]:
return [
{
"fieldname": tf.fieldname,
"label": _(tf.label or tf.fieldname),
"fields": _doctype_docfields(tf.options),
}
for tf in frappe.get_meta(doctype).get_table_fields()
]

View File

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

View File

@@ -210,6 +210,23 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
def is_negative_grand_total_allowed(self) -> bool:
"""Return True if this document may save with a negative grand total.
Sales Order and Purchase Order never post to the GL, so a negative
total is safe there whenever the user has explicitly opted into
negative rates via Selling/Buying Settings. Every other
AccountsController doctype (invoices, delivery notes, receipts,
quotations, ...) keeps relying on the `is_return` escape hatch only.
"""
if self.doctype == "Sales Order":
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
if self.doctype == "Purchase Order":
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
return False
def validate(self):
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -262,7 +279,8 @@ class AccountsController(TransactionBase):
self.calculate_taxes_and_totals()
if not self.meta.get_field("is_return") or not self.is_return:
self.validate_value("base_grand_total", ">=", 0)
if not self.is_negative_grand_total_allowed():
self.validate_value("base_grand_total", ">=", 0)
validate_return(self)

View File

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

View File

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

View File

@@ -888,7 +888,7 @@ def make_bundle_for_material_transfer(**kwargs):
row.stock_value_difference = abs(row.stock_value_difference)
if kwargs.type_of_transaction == "Outward":
row.qty *= -1
row.stock_value_difference *= row.stock_value_difference
row.stock_value_difference *= -1
row.is_outward = 1
row.warehouse = kwargs.warehouse

View File

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

View File

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

View File

@@ -752,3 +752,13 @@ repost_allowed_doctypes = [
"Payment Entry",
"Purchase Receipt",
]
# Data Import
# -----------
# Custom Import Providers plug provider-owned field schema, validation and import logic into
# the standard Data Import. See frappe.core.doctype.data_import.import_provider.
data_import_providers = {
"Customer": "erpnext.selling.doctype.customer.customer_import_provider.CustomerImportProvider",
"Supplier": "erpnext.buying.doctype.supplier.supplier_import_provider.SupplierImportProvider",
}

File diff suppressed because it is too large Load Diff

View File

@@ -314,6 +314,7 @@ class BOM(WebsiteGenerator):
self.clear_inspection()
self.validate_main_item()
self.validate_currency()
self.set_operation_finished_goods()
self.set_materials_based_on_operation_bom()
self.set_conversion_rate()
self.set_plc_conversion_rate()
@@ -340,15 +341,42 @@ class BOM(WebsiteGenerator):
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
def set_operation_finished_goods(self):
"""Fill each operation's FG item where it is unambiguous: the final operation produces
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
if not self.track_semi_finished_goods:
return
for row in self.operations:
if row.is_final_finished_good and not row.finished_good:
row.finished_good = self.item
elif row.bom_no and not row.finished_good:
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods or not self.operations:
return
fg_items = []
for row in self.operations:
if not row.finished_good:
frappe.throw(
_(
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
).format(row.idx, bold(row.operation)),
)
if not row.is_final_finished_good:
continue
if row.finished_good != self.item:
frappe.throw(
_(
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
).format(row.idx, bold(row.operation), bold(self.item)),
)
fg_items.append(row.finished_good)
if not fg_items:
@@ -800,15 +828,10 @@ class BOM(WebsiteGenerator):
row.update(get_item_details(row.get("item_code")))
row.operation_row_id = operation_row_id
item_row = self.get_item_data(row.name) if row.name else None
item_row = self.get_item_data(row.item_code, operation_row_id)
if item_row:
item_row.update(
{
"item_code": row.get("item_code"),
"qty": row.get("qty"),
}
)
item_row.qty = row.get("qty")
else:
row.idx = None
row.name = None
@@ -827,9 +850,9 @@ class BOM(WebsiteGenerator):
return False
def get_item_data(self, name):
def get_item_data(self, item_code, operation_row_id):
for row in self.items:
if row.item_code == name:
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
return row
@frappe.whitelist()

View File

@@ -7,7 +7,7 @@ from functools import partial
import frappe
from frappe.tests import timeout
from frappe.utils import cstr, flt
from frappe.utils import cint, cstr, flt
from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
@@ -919,6 +919,207 @@ class TestBOM(ERPNextTestSuite):
for row in bom.items:
self.assertEqual(row.stock_uom, "Kg")
@timeout
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the first operation produces nothing derivable: no FG item, no BOM to take it from
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[0].finished_good = sfg_item
bom.insert()
# the final operation's FG item is derived from the BOM's own item
self.assertEqual(bom.operations[1].finished_good, fg_item)
@timeout
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.insert()
def rows_for(item_code, operation_row_id):
return [
row
for row in bom.items
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
]
# the item already used by operation 1 gets its own new row under operation 2
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
# adding it again for the same operation updates the row instead of stacking another
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
@timeout
def test_operation_bom_materials_expand_on_single_pass_submit(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"bom_no": sfg_bom.name,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.submit()
self.assertEqual(bom.docstatus, 1)
self.assertEqual(bom.operations[0].finished_good, sfg_item)
self.assertTrue(
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
)
@timeout
def test_final_operation_must_produce_the_bom_item(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
"finished_good": sfg_item,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the final operation claims to produce the semi FG, not this BOM's item
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[1].finished_good = fg_item
bom.insert()
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})

View File

@@ -140,7 +140,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fetch_from": "item_code.stock_uom",
@@ -264,7 +265,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 21:15:55.187671",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Creator Item",

View File

@@ -177,7 +177,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fieldname": "rate_amount_section",
@@ -327,7 +328,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 19:00:38.646539",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",

View File

@@ -213,6 +213,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "FG / Semi FG Item",
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
"options": "Item"
},
{
@@ -307,7 +308,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-25 17:15:42.044630",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -99,6 +99,7 @@
"fieldtype": "Float",
"label": "Conversion Factor",
"non_negative": 1,
"precision": "9",
"reqd": 1
},
{
@@ -217,7 +218,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-16 16:51:40.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Secondary Item",

View File

@@ -891,6 +891,9 @@ class JobCard(Document):
frappe.msgprint(message, alert=True, indicator="orange")
def validate_transfer_qty(self):
if self.track_semi_finished_goods and self.skip_material_transfer:
return
if (
not self.finished_good
and not self.is_corrective_job_card
@@ -1111,6 +1114,9 @@ class JobCard(Document):
wo.calculate_operating_cost()
wo.set_actual_dates()
if wo.track_semi_finished_goods:
wo.set_process_loss_qty()
if time_data:
wo.status = "In Process"
@@ -1461,12 +1467,12 @@ class JobCard(Document):
)
if self.track_semi_finished_goods and previous_operations:
manufactured_qty = self.get_manufactured_qty_per_operation(
[row.name for row in previous_operations]
)
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
for row in previous_operations:
row.manufactured_qty = flt(manufactured_qty.get(row.name))
operation_totals = totals.get(row.name)
row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty)
row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty)
return previous_operations
@@ -1475,7 +1481,11 @@ class JobCard(Document):
data = (
frappe.qb.from_(job_card)
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
.select(
job_card.operation_id,
Sum(job_card.manufactured_qty).as_("manufactured_qty"),
Sum(job_card.process_loss_qty).as_("process_loss_qty"),
)
.where(
(job_card.work_order == self.work_order)
& (job_card.docstatus == 1)
@@ -1483,9 +1493,9 @@ class JobCard(Document):
& (job_card.operation_id.isin(operation_ids))
)
.groupby(job_card.operation_id)
).run()
).run(as_dict=True)
return dict(data)
return {row.operation_id: row for row in data}
def get_current_operation_completed_qty(self):
current_operation_qty = 0.0
@@ -1537,19 +1547,35 @@ class JobCard(Document):
OperationSequenceError,
)
if manufactured_qty < current_operation_qty:
if manufactured_qty >= current_operation_qty:
return
if manufactured_qty + flt(row.process_loss_qty) >= current_operation_qty:
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}, as {4} was booked as process loss there."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation),
bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)),
),
OperationSequenceError,
)
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation),
),
OperationSequenceError,
)
def validate_work_order(self):
if self.is_work_order_closed():
frappe.throw(_("You cannot make any changes to Job Card since Work Order is closed."))
@@ -1801,10 +1827,11 @@ class JobCard(Document):
def build_manufacture_stock_entry(self):
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
consumed_process_loss = self.get_consumed_process_loss()
return ManufactureEntry(
{
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,

View File

@@ -1447,6 +1447,204 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(flt(job_card.manufactured_qty), 3)
self.assertEqual(job_card.status, "Completed")
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Process Loss Rollup Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=8,
for_quantity=10,
pending_qty=0,
process_loss_qty=2,
end_time="2024-05-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.process_loss_qty), 2)
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
self.assertEqual(
flt(
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
),
2,
)
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
"""Loss booked by an earlier operation shrinks what the final operation can produce,
so it has to show up on the work order even though the final operation loses nothing."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operations = [
{
"operation": "Intermediate Loss Op A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"sequence_id": 1,
},
{
"operation": "Intermediate Loss Op B",
"finished_good": fg,
"is_final_finished_good": 1,
"sequence_id": 2,
},
]
for row in operations:
row.update(
{
"workstation": "_Test Workstation A",
"finished_good_qty": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
)
make_workstation(row)
make_operation(row)
fg_bom.append("operations", row)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
for row in work_order.operations:
row.time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
def get_job_card(operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card",
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
"name",
),
)
jc_a = get_job_card("Intermediate Loss Op A")
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
jc_a.save()
jc_a.complete_job_card(
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
)
jc_a.reload()
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.process_loss_qty), 2)
# Operation A handed over only 8 units, so the final operation works on 8.
jc_b = get_job_card("Intermediate Loss Op B")
jc_b.for_quantity = 8
for row in jc_b.items:
row.required_qty = 8
jc_b.append(
"time_logs",
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
)
jc_b.save()
jc_b.submit()
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -1726,6 +1924,299 @@ class TestJobCard(ERPNextTestSuite):
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item("PL Scope RM 1", {"is_stock_item": 1}).name
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": "PL Scope Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "PL Scope Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 2
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
# operation A's loss must not leak into operation B's entry
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
fg_row = next(row for row in me_b.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 3.0)
me_b.submit()
def make_semi_fg_work_order(self, prefix, qty=5):
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
consumes it. Both operations skip material transfer; stock is pre-seeded."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item(f"{prefix} RM 1", {"is_stock_item": 1}).name
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": f"{prefix} Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": f"{prefix} Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=qty,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
for item_code in (rm1, rm2, sfg):
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
return work_order
def get_semi_fg_job_card(self, work_order, operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
)
def test_partial_manufacture_entry_then_finish(self):
work_order = self.make_semi_fg_work_order("PL Partial")
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
# so it accounts for 3 of 5 and its materials are trimmed to the same share
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in first.items if row.is_finished_item)
fg_row.qty = 1
for row in first.items:
if row.s_warehouse and not row.is_finished_item:
row.qty = flt(row.qty) * 3 / 5
first.save()
first.submit()
# the follow-up entry must be generated net of the already-booked loss and still submit
jc_b.reload()
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in second.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 2.0)
self.assertEqual(flt(second.process_loss_qty), 0.0)
second.submit()
jc_b.reload()
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
# across both entries, consumption adds up to the job card's requirement of 5, no more
consumed = frappe.get_all(
"Stock Entry Detail",
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
fields=["item_code", {"SUM": "qty", "as": "qty"}],
group_by="item_code",
)
self.assertTrue(consumed)
for row in consumed:
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
def test_update_after_submit_keeps_manufacture_entry_intact(self):
work_order = self.make_semi_fg_work_order("PL Update")
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
entry.submit()
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
entry.reload()
entry.select_print_heading = "_Test SFG Heading"
entry.save()
entry.reload()
self.assertEqual(flt(entry.process_loss_qty), 2.0)
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
work_order = self.make_semi_fg_work_order("PL NoBom")
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_one.submit()
stale = frappe.get_doc("Stock Entry", draft_two.name)
self.assertRaises(frappe.ValidationError, stale.submit)
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -2302,3 +2793,43 @@ class TestJobCardLogic(ERPNextTestSuite):
self.assertTrue(jc.has_overlap(1, sequential))
self.assertFalse(jc.has_overlap(2, sequential))
self.assertTrue(jc.has_overlap(2, overlapping))
def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self):
jc = frappe.new_doc("Job Card")
jc.operation = "_Test Painting"
jc.stock_uom = "Nos"
row = frappe._dict(
operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None
)
with self.assertRaises(OperationSequenceError) as loss_error:
jc.validate_previous_operation_manufactured_qty(row, 10)
self.assertIn("process loss", str(loss_error.exception))
row.process_loss_qty = 0
with self.assertRaises(OperationSequenceError) as pending_error:
jc.validate_previous_operation_manufactured_qty(row, 10)
self.assertIn("Submit the manufacturing entry", str(pending_error.exception))
jc.validate_previous_operation_manufactured_qty(row, 8)
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
jc = frappe.new_doc("Job Card")
jc.track_semi_finished_goods = 1
jc.skip_material_transfer = 1
jc.for_quantity = 10
jc.transferred_qty = 0
jc.append("items", {"item_code": "_Test Item"})
jc.validate_transfer_qty()
# with transfer enabled, a legacy card without an FG item keeps the strict check
jc.skip_material_transfer = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
jc.finished_good = "_Test Item"
jc.validate_transfer_qty()
jc.finished_good = None
jc.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)

View File

@@ -193,6 +193,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -266,7 +267,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-10-30 17:01:25.996352",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",

View File

@@ -11,6 +11,7 @@ existing imports of ``...services.material_planning`` keep working through here.
import copy
import json
from collections import defaultdict
from decimal import ROUND_CEILING, Decimal
import frappe
from frappe import _, msgprint
@@ -493,8 +494,16 @@ def get_material_request_items(
)
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
return _material_request_item_row(
row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
row,
sales_order,
target_warehouse,
bin_dict,
required_qty,
conversion_factor,
item_group_defaults,
min_order_qty,
)
@@ -539,6 +548,18 @@ def _adjust_required_qty_for_uom(row, required_qty):
return required_qty
def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0):
"""Convert to purchase UOM; a binding minimum order qty takes the smallest
representable quantity whose stock equivalent still meets it."""
precision = frappe.get_precision("Material Request Plan Item", "quantity")
quantity = flt(required_qty / conversion_factor, precision)
if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty:
grid = Decimal(10) ** -precision
exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor))
quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING))
return quantity
def _mr_purchase_conversion_factor(row):
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
if (
@@ -551,7 +572,14 @@ def _mr_purchase_conversion_factor(row):
def _material_request_item_row(
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
row,
sales_order,
warehouse,
bin_dict,
required_qty,
conversion_factor,
item_group_defaults,
min_order_qty=0,
):
warehouse = (
warehouse
@@ -559,11 +587,10 @@ def _material_request_item_row(
or row.get("default_warehouse")
or item_group_defaults.get("default_warehouse")
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
return {
"item_code": row.item_code,
"item_name": row.item_name,
"quantity": flt(required_qty / conversion_factor, precision),
"quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty),
"conversion_factor": conversion_factor,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
@@ -640,7 +667,8 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0
item["quantity"] = _quantity_in_purchase_uom(required_qty, item.get("conversion_factor"), min_order_qty)
new_mr_items.append(item)

View File

@@ -2384,6 +2384,73 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
def test_min_order_qty_conversion_takes_grid_ceiling(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_quantity_in_purchase_uom,
)
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232)
self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249)
self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231)
def test_min_order_qty_grid_ceiling_in_plan_items(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1)
pln.consider_minimum_order_qty = 1
mr_items = get_items_for_material_requests(pln.as_dict())
self.assertEqual(mr_items[0].get("quantity"), 110.232)
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
def test_min_order_qty_grid_ceiling_from_other_locations(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company")
source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse)
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1)
pln.for_warehouse = rm_warehouse
pln.consider_minimum_order_qty = 1
pln.ignore_existing_ordered_qty = 1
mr_items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
)
rows_by_type = {d.get("material_request_type"): d for d in mr_items}
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232)
def test_fg_item_quantity(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name

View File

@@ -9,6 +9,7 @@ the controller; work_order.py re-exports them for backward compatibility.
"""
import json
import math
from functools import partial
import frappe
@@ -476,42 +477,104 @@ def create_pick_list(
):
frappe.has_permission("Pick List", "create", throw=True)
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
postprocess = partial(
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
)
if for_qty is None:
for_qty = frappe.parse_json(target_doc or "{}").get("for_qty")
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
for_qty = _validated_for_qty(for_qty)
work_order = frappe.get_doc("Work Order", source_name)
allocation = _allocate_material_demand(work_order, for_qty / flt(work_order.qty))
postprocess = partial(_set_pick_list_item_qty, allocation_by_item=allocation)
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess, allocation), target_doc)
_validate_material_is_pending(doc.locations)
doc.purpose = "Material Transfer for Manufacture"
doc.for_qty = for_qty
doc.set_item_locations()
return doc
def _pick_list_mapping(postprocess):
def _pick_list_mapping(postprocess, allocation):
return {
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": postprocess,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
def _allocate_material_demand(work_order, fraction):
"""Fraction of each (item, warehouse, operation row) group's requirement, capped
at the group's proportional share of the item's pending pool."""
required_by_item = {}
covered_by_item = {}
required_by_group = {}
for row in work_order.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
covered_by_item.setdefault(
row.item_code,
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty),
)
key = _allocation_key(row)
required_by_group[key] = required_by_group.get(key, 0.0) + flt(row.required_qty)
qty = 0
if desire_to_transfer <= pending_to_issue:
qty = desire_to_transfer
elif pending_to_issue > 0:
qty = pending_to_issue
pending_pool = {
item_code: required_qty - covered_by_item[item_code]
for item_code, required_qty in required_by_item.items()
}
if not qty:
allocation = {}
for key, required_qty in required_by_group.items():
item_code = key[0]
if required_by_item[item_code] <= 0:
continue
pool_share = pending_pool[item_code] * required_qty / required_by_item[item_code]
qty = min(required_qty * fraction, pool_share)
if qty > 0:
allocation[key] = qty
return allocation
def _allocation_key(row):
"""Manual rows have no operation_row_id; their operation label splits them."""
return (row.item_code, row.source_warehouse, cint(row.operation_row_id) or row.operation)
def _merge_allocation_per_item(allocation):
"""Material Request rejects repeated item codes unless Buying Settings allows them."""
merged = {}
key_by_item = {}
for key, qty in allocation.items():
item_code = key[0]
if item_code in key_by_item:
merged[key_by_item[item_code]] += qty
else:
key_by_item[item_code] = key
merged[key] = qty
return merged
def _validated_for_qty(for_qty):
qty = flt(for_qty)
if not math.isfinite(qty) or qty <= 0:
frappe.throw(_("Quantity must be greater than zero."))
return qty
def _validate_material_is_pending(rows):
if not rows:
frappe.throw(
_("All required items have already been transferred, requested or picked."),
title=_("No Pending Materials"),
)
def _set_pick_list_item_qty(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
@@ -523,15 +586,32 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
def make_material_request(
source_name: str, target_doc: str | dict | Document | None = None, for_qty: float | None = None
):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
if for_qty is None and frappe.flags.args:
for_qty = frappe.flags.args.for_qty
work_order = frappe.get_doc("Work Order", source_name)
fraction = 1.0
if for_qty is not None:
fraction = _validated_for_qty(for_qty) / flt(work_order.qty)
allocation = _allocate_material_demand(work_order, fraction)
if not cint(frappe.db.get_single_value("Buying Settings", "allow_multiple_items")):
allocation = _merge_allocation_per_item(allocation)
postprocess = partial(_set_material_request_item, allocation_by_item=allocation)
doc = get_mapped_doc(
"Work Order", source_name, _material_request_mapping(postprocess, allocation), target_doc
)
_validate_material_is_pending(doc.items)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
def _material_request_mapping(postprocess, allocation):
return {
"Work Order": {
"doctype": "Material Request",
@@ -541,19 +621,23 @@ def _material_request_mapping():
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("required_qty", "qty"),
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"postprocess": postprocess,
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_material_request_item(source, target, source_parent):
def _set_material_request_item(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.qty = qty
target.schedule_date = nowdate()

View File

@@ -9,6 +9,7 @@ callers and the whitelisted entry point keep working unchanged.
"""
import frappe
from frappe import _
from frappe.utils import flt
from pypika import functions as fn
@@ -198,6 +199,97 @@ class RequiredItemsService:
for row in self.doc.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
def validate_incoming_material_demand(self, incoming_qty_by_item):
"""Reject demand exceeding the pending requirement; callers must hold the
work order row lock (for_update=True)."""
required_by_item = {}
uom_by_item = {}
for row in self.doc.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
uom_by_item.setdefault(row.item_code, row.stock_uom)
transferred = self._material_transfer_qty_by_item(is_return=0)
requested = self._material_request_pending_qty_by_item()
picked = self._pick_list_pending_qty_by_item()
for item_code, incoming_qty in incoming_qty_by_item.items():
if item_code not in required_by_item:
continue
pending = (
required_by_item[item_code]
- flt(transferred.get(item_code))
- flt(requested.get(item_code))
- flt(picked.get(item_code))
)
if flt(incoming_qty - pending, 6) > 0:
frappe.throw(
_("Only {0} {1} of {2} is pending in Work Order {3}.").format(
max(pending, 0.0), uom_by_item[item_code], item_code, self.doc.name
),
title=_("Exceeds Pending Qty"),
)
def update_requested_qty_for_required_items(self):
"""Refresh per-row qty requested via open Material Requests but not yet transferred."""
requested_items = self._material_request_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("requested_qty", (requested_items.get(row.item_code) or 0.0), update_modified=False)
def _material_request_pending_qty_by_item(self):
mr = frappe.qb.DocType("Material Request")
mr_item = frappe.qb.DocType("Material Request Item")
query = (
frappe.qb.from_(mr)
.inner_join(mr_item)
.on(mr_item.parent == mr.name)
.select(mr_item.item_code, fn.Sum(mr_item.stock_qty - mr_item.ordered_qty).as_("qty"))
.where(
(mr.docstatus == 1)
& (mr.work_order == self.doc.name)
& (mr.material_request_type == "Material Transfer")
& (mr.status != "Stopped")
& (mr_item.stock_qty > mr_item.ordered_qty)
)
.groupby(mr_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def update_picked_qty_for_required_items(self):
"""Refresh per-row qty picked but not yet transferred. Rows of a live material
request count as requested_qty instead, until that request stops or cancels."""
picked_items = self._pick_list_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("picked_qty", (picked_items.get(row.item_code) or 0.0), update_modified=False)
def _pick_list_pending_qty_by_item(self):
pick_list = frappe.qb.DocType("Pick List")
pick_list_item = frappe.qb.DocType("Pick List Item")
mr = frappe.qb.DocType("Material Request")
query = (
frappe.qb.from_(pick_list)
.inner_join(pick_list_item)
.on(pick_list_item.parent == pick_list.name)
.left_join(mr)
.on(pick_list_item.material_request == mr.name)
.select(
pick_list_item.item_code,
fn.Sum(pick_list_item.picked_qty - pick_list_item.transferred_qty).as_("qty"),
)
.where(
(pick_list.docstatus == 1)
& (pick_list.work_order == self.doc.name)
& (pick_list_item.picked_qty > pick_list_item.transferred_qty)
& (
(fn.Coalesce(pick_list_item.material_request_item, "") == "")
| (mr.docstatus != 1)
| (mr.status == "Stopped")
)
)
.groupby(pick_list_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")

View File

@@ -291,6 +291,12 @@ class StatusService:
)
def set_process_loss_qty(self):
self.doc.db_set("process_loss_qty", self._process_loss_qty())
def _process_loss_qty(self):
if self.doc.track_semi_finished_goods:
return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations))
table = frappe.qb.DocType("Stock Entry")
process_loss_qty = (
frappe.qb.from_(table)
@@ -302,7 +308,7 @@ class StatusService:
)
).run()[0][0]
self.doc.db_set("process_loss_qty", flt(process_loss_qty))
return flt(process_loss_qty)
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.doc.production_plan)

View File

@@ -1638,6 +1638,359 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_material_request_qty_scales_with_requested_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
mr = make_material_request(work_order.name, for_qty=4)
self.assertEqual(len(mr.items), len(required_qty))
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code] * 4 / 10)
mr = make_material_request(work_order.name)
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code])
def test_material_request_qty_capped_at_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
partially_transferred = work_order.required_items[0]
partially_transferred.db_set("transferred_qty", flt(partially_transferred.required_qty) - 1)
work_order.reload()
mr = make_material_request(work_order.name, for_qty=10)
requested_qty = {row.item_code: row.qty for row in mr.items}
self.assertEqual(requested_qty[partially_transferred.item_code], 1)
def test_material_request_maps_only_selected_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
selected = work_order.required_items[0]
try:
frappe.flags.selected_children = {"required_items": [selected.name]}
mr = make_material_request(work_order.name, for_qty=4)
finally:
frappe.flags.selected_children = None
self.assertEqual([row.item_code for row in mr.items], [selected.item_code])
self.assertEqual(mr.items[0].qty, flt(selected.required_qty) * 4 / 10)
def test_material_request_rejects_nonpositive_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=-1)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("inf")
)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("nan")
)
def test_pick_list_rejects_nonpositive_qty(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=-1)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("inf"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("nan"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name)
def submit_material_request(self, work_order_name, for_qty=None):
mr = make_material_request(work_order_name, for_qty=for_qty)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
mr.submit()
return mr
def receive_test_fg_raw_materials(self):
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=1000.0
)
def test_requested_qty_tracks_open_material_requests(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name, for_qty=10)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
mr.cancel()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
def test_requested_qty_moves_to_transferred_qty_on_stock_entry(self):
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
stock_entry.insert()
stock_entry.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.transferred_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
def test_picked_qty_tracks_open_pick_lists(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
pick_list = create_pick_list(work_order.name, for_qty=4)
pick_list.insert()
pick_list.submit()
picked_qty = {row.item_code: flt(row.stock_qty) for row in pick_list.locations}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.picked_qty, picked_qty[row.item_code])
remainder_pick_list = create_pick_list(work_order.name, for_qty=10)
for row in remainder_pick_list.locations:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - picked_qty[row.item_code])
remainder_pick_list.insert()
remainder_pick_list.submit()
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=10)
def test_material_request_submit_rejects_exceeding_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
def full_draft():
mr = make_material_request(work_order.name)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
return mr
first, second = full_draft(), full_draft()
first.submit()
self.assertRaises(frappe.ValidationError, second.submit)
def test_picked_qty_counts_pick_list_of_stopped_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
self.assertEqual(row.picked_qty, 0)
mr.reload()
mr.update_status("Stopped")
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.picked_qty, mr_qty[row.item_code])
def test_pending_demand_shared_across_duplicate_item_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
total_required = flt(first.required_qty) + 5
mr = self.submit_material_request(work_order.name, for_qty=4)
requested = sum(flt(row.qty) for row in mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(requested, total_required * 4 / 10, places=6)
work_order.reload()
for row in work_order.required_items:
if row.item_code == first.item_code:
self.assertAlmostEqual(row.requested_qty, requested, places=6)
remainder_mr = make_material_request(work_order.name, for_qty=10)
remainder = sum(flt(row.qty) for row in remainder_mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(remainder, total_required - requested, places=6)
def test_allocation_splits_by_source_warehouse(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = {row.from_warehouse: flt(row.qty) for row in mr.items if row.item_code == first.item_code}
self.assertEqual(len(rows), 2)
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
mr = self.submit_material_request(work_order.name, for_qty=4)
rows = [row for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 1)
self.assertAlmostEqual(flt(rows[0].qty), (flt(first.required_qty) + 5) * 4 / 10, places=6)
def test_remainder_allocation_splits_proportionally_across_groups(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
self.submit_material_request(work_order.name, for_qty=4)
work_order.reload()
remainder = make_material_request(work_order.name, for_qty=10)
rows = {
row.from_warehouse: flt(row.qty) for row in remainder.items if row.item_code == first.item_code
}
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 6 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 6 / 10, places=6)
def test_allocation_splits_manual_rows_by_operation_label(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
for operation in ("_Test Operation A", "_Test Operation B"):
row = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"operation": operation,
"docstatus": 1,
},
)
row.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = [flt(row.qty) for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 3)
self.assertAlmostEqual(sum(rows), (flt(first.required_qty) + 10) * 4 / 10, places=6)
def test_pick_list_rejects_over_pick_against_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.locations[0].picked_qty = flt(pick_list.locations[0].stock_qty) + 1
self.assertRaises(frappe.ValidationError, pick_list.submit)
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",
@@ -5108,6 +5461,24 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(flt(qty_by_item.get(item_a)), 10.0)
self.assertEqual(flt(qty_by_item.get(item_b)), 10.0)
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
wo = frappe.new_doc("Work Order")
wo.track_semi_finished_goods = 1
wo.skip_transfer = 0
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
wo.wip_warehouse = "_Test Warehouse - _TC"
wo.validate_warehouse()
# the top-level target warehouse stays optional; operations may carry their own
wo.fg_warehouse = None
wo.validate_warehouse()
wo.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -852,7 +852,10 @@ erpnext.work_order = {
function () {
let purpose = "Material Transfer for Manufacture";
erpnext.work_order
.show_prompt_for_qty_input(frm, purpose, qty, 1)
.show_prompt_for_qty_input(frm, purpose, {
qty: qty,
additional_transfer_entry: 1,
})
.then((data) => {
return frappe.xcall(
"erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
@@ -1038,6 +1041,26 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
get_max_requestable_qty: (frm) => {
const required = {};
const covered = {};
(frm.doc.required_items || []).forEach((row) => {
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
if (!(row.item_code in covered)) {
covered[row.item_code] =
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
}
});
let max_fraction = 0;
Object.keys(required).forEach((item_code) => {
if (required[item_code] <= 0) return;
const pending = required[item_code] - covered[item_code];
max_fraction = Math.max(max_fraction, pending / required[item_code]);
});
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
@@ -1092,20 +1115,20 @@ erpnext.work_order = {
});
},
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
let max = qty == null ? this.get_max_transferable_qty(frm, purpose) : qty;
let fields = [
{
fieldtype: "Float",
label: __("Qty for {0}", [__(purpose)]),
label: __("Qty for {0}", [target || __(purpose)]),
fieldname: "qty",
description: __("Max: {0}", [max]),
default: max,
},
];
if (!additional_transfer_entry) {
if (!additional_transfer_entry && !target) {
fields.push({
fieldtype: "Check",
label: __("Consider Process Loss"),
@@ -1127,6 +1150,11 @@ erpnext.work_order = {
(data) => {
max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100;
if (!data.qty || data.qty <= 0) {
frappe.msgprint(__("Quantity must be greater than zero."));
reject();
return;
}
if (data.qty > max) {
frappe.msgprint(__("Quantity must not be more than {0}", [max]));
reject();
@@ -1169,15 +1197,32 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
});
make_material_request: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_material_request = (for_qty) =>
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
args: { for_qty: for_qty },
});
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Material Request"),
}).then((data) => get_material_request(data.qty));
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_pick_list = (for_qty) =>
frappe
@@ -1190,11 +1235,10 @@ erpnext.work_order = {
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
if (max <= 0) {
get_pick_list(frm.doc.qty);
} else {
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
}
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Pick List"),
}).then((data) => get_pick_list(data.qty));
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {

View File

@@ -272,7 +272,7 @@
"fieldtype": "Link",
"label": "Work-in-Progress Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods",
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
"options": "Warehouse"
},
{
@@ -739,7 +739,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-06-03 21:35:34.175667",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -601,12 +601,9 @@ class WorkOrder(Document):
)
def validate_warehouse(self):
if self.track_semi_finished_goods:
return
if not self.wip_warehouse and not self.skip_transfer:
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
if not self.fg_warehouse:
if not self.fg_warehouse and not self.track_semi_finished_goods:
frappe.throw(_("Target Warehouse is required before Submit"))
def before_submit(self):

View File

@@ -22,6 +22,8 @@
"amount",
"column_break_11",
"transferred_qty",
"requested_qty",
"picked_qty",
"consumed_qty",
"returned_qty",
"section_break_idhr",
@@ -93,6 +95,22 @@
"label": "Transferred Qty",
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "requested_qty",
"fieldtype": "Float",
"label": "Requested Qty",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!parent.subcontracting_inward_order",
@@ -209,7 +227,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2026-05-12 12:05:16.687866",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -31,8 +31,10 @@ class WorkOrderItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
picked_qty: DF.Float
rate: DF.Currency
required_qty: DF.Float
requested_qty: DF.Float
returned_qty: DF.Float
source_warehouse: DF.Link | None
stock_reserved_qty: DF.Float

View File

@@ -508,3 +508,5 @@ erpnext.patches.v16_0.move_warehouse_defaults_to_company
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
erpnext.patches.v16_0.rename_italy_customer_name_fields

View File

@@ -1,3 +1,4 @@
import frappe
from frappe import qb
@@ -13,5 +14,8 @@ def execute():
"Payment Reconciliation Allocation",
]
for x in doctypes:
# child tables may not exist yet on sites where this pre-model-sync patch runs first
if not frappe.db.table_exists(x):
continue
dt = qb.DocType(x)
qb.from_(dt).delete().run()

View File

@@ -0,0 +1,53 @@
import frappe
RENAMED_FIELDS = {
"first_name": "italy_customer_first_name",
"last_name": "italy_customer_last_name",
}
def execute():
"""Rename Italy's Customer name fields, which clash with the standard quick-entry
first_name/last_name fields, and restore any Italy custom field columns that a
previously interrupted fixture run left missing."""
if not has_italy_fixtures():
return
duplicate_fieldnames = [
fieldname for fieldname in RENAMED_FIELDS if frappe.db.exists("Custom Field", f"Customer-{fieldname}")
]
from erpnext.regional.italy.setup import get_custom_fields, make_custom_fields
make_custom_fields()
for doctype in get_custom_fields():
frappe.clear_cache(doctype=doctype)
frappe.db.updatedb(doctype)
for old_fieldname, new_fieldname in RENAMED_FIELDS.items():
copy_customer_names(old_fieldname, new_fieldname)
for old_fieldname in duplicate_fieldnames:
frappe.delete_doc("Custom Field", f"Customer-{old_fieldname}", force=True)
if duplicate_fieldnames:
frappe.clear_cache(doctype="Customer")
def has_italy_fixtures():
return bool(
frappe.db.exists("Company", {"country": "Italy"})
or frappe.db.exists("Custom Field", "Company-fiscal_regime")
)
def copy_customer_names(old_fieldname, new_fieldname):
customer = frappe.qb.DocType("Customer")
old_column = customer[old_fieldname]
new_column = customer[new_fieldname]
(
frappe.qb.update(customer)
.set(new_column, old_column)
.where(old_column.isnotnull() & (old_column != ""))
.where(new_column.isnull() | (new_column == ""))
).run()

View File

@@ -0,0 +1,38 @@
import frappe
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
def execute():
"""Backfill requested_qty and picked_qty for work orders with open demand;
fulfilled documents leave the zero default."""
work_orders = set(
frappe.get_all(
"Material Request",
filters={
"docstatus": 1,
"material_request_type": "Material Transfer",
"work_order": ("is", "set"),
"status": ("!=", "Stopped"),
"per_ordered": ("<", 100),
},
pluck="work_order",
distinct=True,
)
)
work_orders.update(
frappe.get_all(
"Pick List",
filters={"docstatus": 1, "work_order": ("is", "set"), "status": ("!=", "Completed")},
pluck="work_order",
distinct=True,
)
)
for name in work_orders:
if frappe.db.get_value("Work Order", name, "docstatus") != 1:
continue
service = RequiredItemsService(frappe.get_doc("Work Order", name))
service.update_requested_qty_for_required_items()
service.update_picked_qty_for_required_items()

View File

@@ -90,6 +90,7 @@ class Task(NestedSet):
self.validate_completed_on()
self.set_default_end_date_if_missing()
self.validate_parent_is_group()
self.validate_web_form_project_permission()
def validate_dates(self):
self.validate_from_to_dates("exp_start_date", "exp_end_date")
@@ -313,6 +314,23 @@ class Task(NestedSet):
if project_user:
return True
def validate_web_form_project_permission(self):
project_unchanged = not self.is_new() and self.project == self.get_db_value("project")
if (
not frappe.flags.in_web_form
or not self.project
or project_unchanged
or frappe.has_permission("Project", "write", doc=self.project)
or self.has_webform_permission()
):
return
frappe.throw(
_("You are not permitted to create a Task for Project {0}").format(self.project),
frappe.PermissionError,
)
def populate_depends_on(self):
if self.parent_task:
parent = frappe.get_doc("Task", self.parent_task)

View File

@@ -456,7 +456,7 @@ const set_employee_and_company = function (frm) {
const options = { user_id: frappe.session.user };
const fields = ["name", "company"];
frappe.db.get_value("Employee", options, fields).then(({ message }) => {
if (message) {
if (message.name && message.company) {
// there is an employee with the currently logged in user_id
frm.set_value("employee", message.name);
frm.set_value("company", message.company);

View File

@@ -1802,7 +1802,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let item = frappe.get_doc(cdt, cdn);
item.conversion_factor = 1.0;
if (item.stock_qty) {
item.conversion_factor = flt(item.stock_qty) / flt(item.qty);
item.conversion_factor = flt(
flt(item.stock_qty) / flt(item.qty),
precision("conversion_factor", item)
);
}
refresh_field("conversion_factor", item.name, item.parentfield);

View File

@@ -742,6 +742,7 @@ erpnext.utils.update_child_items = function (opts) {
qty: d.qty,
rate: d.rate,
uom: d.uom,
warehouse: d.warehouse,
fg_item: d.fg_item,
fg_item_qty: d.fg_item_qty,
description: d.description,
@@ -829,6 +830,7 @@ erpnext.utils.update_child_items = function (opts) {
item_name,
bom_no,
description,
warehouse,
} = r.message;
const row = dialog.fields_dict.trans_items.df.data.find(
(row) => row.name == me.doc.name
@@ -842,6 +844,7 @@ erpnext.utils.update_child_items = function (opts) {
item_name: item_name,
bom_no: bom_no,
description: me.doc.description || description,
warehouse: me.doc.docname ? me.doc.warehouse : warehouse,
});
dialog.fields_dict.trans_items.grid.refresh();
}
@@ -929,6 +932,29 @@ erpnext.utils.update_child_items = function (opts) {
});
}
const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse");
if (warehouse_df) {
fields.splice(3, 0, {
fieldtype: "Link",
fieldname: "warehouse",
options: "Warehouse",
in_list_view: 1,
label: __(warehouse_df.label),
// only new rows may set it, existing rows would leave their
// reserved qty stranded in the previous warehouse's bin
read_only_depends_on: "eval:doc.docname",
get_query: () => {
return {
filters: {
company: frm.doc.company,
is_group: 0,
disabled: 0,
},
};
},
});
}
if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) {
fields.push(
{

View File

@@ -99,8 +99,8 @@
{%- if doc.customer_data.customer_type == "Individual" %}
<CodiceFiscale>{{ doc.customer_data.fiscal_code }}</CodiceFiscale>
<Anagrafica>
<Nome>{{ doc.customer_data.first_name }}</Nome>
<Cognome>{{ doc.customer_data.last_name }}</Cognome>
<Nome>{{ doc.customer_data.italy_customer_first_name }}</Nome>
<Cognome>{{ doc.customer_data.italy_customer_last_name }}</Cognome>
</Anagrafica>
{%- else %}
<IdFiscaleIVA>

View File

@@ -23,6 +23,10 @@ def setup(company=None, patch=True):
def make_custom_fields(update=True):
create_custom_fields(get_custom_fields(), ignore_validate=frappe.flags.in_patch, update=update)
def get_custom_fields():
invoice_item_fields = [
dict(
fieldname="tax_rate",
@@ -96,7 +100,7 @@ def make_custom_fields(update=True):
),
]
custom_fields = {
return {
"Company": [
dict(
fieldname="sb_e_invoicing",
@@ -232,18 +236,18 @@ def make_custom_fields(update=True):
depends_on='eval:doc.customer_type=="Company"',
),
dict(
fieldname="first_name",
fieldname="italy_customer_first_name",
label="First Name",
fieldtype="Data",
insert_after="salutation",
insert_after="customer_type",
print_hide=1,
depends_on='eval:doc.customer_type!="Company"',
),
dict(
fieldname="last_name",
fieldname="italy_customer_last_name",
label="Last Name",
fieldtype="Data",
insert_after="first_name",
insert_after="italy_customer_first_name",
print_hide=1,
depends_on='eval:doc.customer_type!="Company"',
),
@@ -461,8 +465,6 @@ def make_custom_fields(update=True):
],
}
create_custom_fields(custom_fields, ignore_validate=frappe.flags.in_patch, update=update)
def setup_report():
report_name = "Electronic Invoice Register"

View File

@@ -199,7 +199,8 @@ class Customer(TransactionBase):
self.loyalty_program_tier = customer.loyalty_program_tier
if self.sales_team:
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
total = sum(flt(member.allocated_percentage) for member in self.sales_team)
if flt(total, self.precision("allocated_percentage", "sales_team")) != 100:
frappe.throw(_("Total contribution percentage should be equal to 100"))
@frappe.whitelist(methods=["POST"])

View File

@@ -0,0 +1,249 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Data Import provider for Customer.
Imports a Customer together with its Contact and Address records. In the schema Contact and
Address are separate doctypes linked to the Customer via a Dynamic Link; the provider exposes
them as child tables in the import and wires up the links + primary flags in ``import_row``.
"""
import frappe
from frappe import _
from frappe.core.doctype.data_import.import_provider import ImportProvider
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
from .mapper import parse_full_name
class CustomerImportProvider(ImportProvider):
def get_import_fields(self) -> dict:
"""Field schema for the picker: a Customer with its own child tables, plus Contact and
Address (linked doctypes exposed as extra child tables).
For Customer the columns ARE those doctypes' fields, so we read complete docfields from
meta. (This is the provider's choice — Data Import itself never reads meta here.)
"""
return {
"fields": _doctype_docfields("Customer"),
"child_tables": [
*_doctype_child_tables("Customer"),
{
"fieldname": "contacts",
"label": _("Contact"),
"fields": _contact_docfields(),
},
{
"fieldname": "addresses",
"label": _("Address"),
"fields": _doctype_docfields("Address", prefer_plain_label=True),
},
],
}
def validate(self, import_file) -> list[dict]:
"""Business checks on top of the framework's basic Select/Link/Date validation."""
warnings = []
for payload in import_file.get_payloads_for_import():
doc = payload.doc
row = payload.rows[0].row_number if payload.rows else None
for contact in doc.get("contacts") or []:
# A Contact is only useful with a way to reach it — warn when both are missing.
if not (contact.get("email_id") or contact.get("mobile_no")):
missing = [
label
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
if not contact.get(field)
]
warnings.append(
{
"row": row,
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
}
)
for address in doc.get("addresses") or []:
if not address.get("address_line1"):
continue
missing = [
label
for field, label in (("city", _("City")), ("country", _("Country")))
if not address.get(field)
]
if missing:
warnings.append(
{
"row": row,
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
}
)
return warnings
def import_row(self, importer, doc):
"""Persist Customer per Import Type, then create linked Contacts/Addresses."""
contact_rows = doc.pop("contacts", None) or []
address_rows = doc.pop("addresses", None) or []
has_child_rows = bool(contact_rows or address_rows)
customer, import_action = self._persist_customer(importer, doc, has_child_rows)
self._create_contacts(customer, contact_rows)
self._create_addresses(customer, address_rows)
return customer, import_action
def _persist_customer(self, importer, doc, has_child_rows):
"""Use the core Importer paths so provider imports honor Insert/Update/Upsert semantics."""
if importer.import_type == INSERT:
return importer.insert_record(doc), None
if importer.import_type == UPDATE:
# Allow child-only updates (e.g. add Contact/Address) without failing on unchanged Customer fields.
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
return importer.upsert_record(doc)
def _create_contacts(self, customer, rows):
primary = None
for row in rows:
row = dict(row)
email = row.pop("email_id", None)
mobile = row.pop("mobile_no", None)
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
first_name, last_name, company_name = self._resolve_contact_names(customer, row)
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
if first_name:
contact_values["first_name"] = first_name
if last_name:
contact_values["last_name"] = last_name
if company_name:
contact_values["company_name"] = company_name
contact = frappe.get_doc(
{
"doctype": "Contact",
**contact_values,
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
}
)
if email:
contact.add_email(email, is_primary=True)
if mobile:
contact.add_phone(mobile, is_primary_mobile_no=True)
contact.insert()
# First created contact is the default primary; an explicit flag overrides.
if flagged or primary is None:
primary = contact
if primary:
# Contact has no cross-contact auto-demotion (unlike Address's
# validate_preferred_address), so explicitly demote any other primary Contact on
# this party first — otherwise get_default_contact may return a Contact other
# than customer_primary_contact.
_demote_other_primary_contacts("Customer", customer.name, primary.name)
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
customer.db_set("customer_primary_contact", primary.name)
customer.db_set("mobile_no", primary.mobile_no)
customer.db_set("email_id", primary.email_id)
def _resolve_contact_names(self, customer, row):
"""Resolve Contact names from row data and fall back to Customer data when needed."""
first_name = row.pop("first_name", None)
last_name = row.pop("last_name", None)
company_name = row.pop("company_name", None)
customer_get = getattr(customer, "get", None)
def get_customer_value(fieldname):
if callable(customer_get):
return customer_get(fieldname)
return getattr(customer, fieldname, None)
if customer.customer_type == "Individual":
first_name = first_name or get_customer_value("first_name")
last_name = last_name or get_customer_value("last_name")
if not first_name and customer.customer_name:
parsed_first, _, parsed_last = parse_full_name(customer.customer_name)
first_name = parsed_first
last_name = last_name or parsed_last
return first_name, last_name, company_name
def _create_addresses(self, customer, rows):
from frappe.contacts.doctype.address.address import get_address_display
primary = None
for row in rows:
row = dict(row)
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
if not row.get("address_line1"):
continue
row["address_type"] = row.get("address_type") or "Billing"
row["address_title"] = row.get("address_title") or customer.customer_name
address = frappe.get_doc(
{
"doctype": "Address",
**{k: v for k, v in row.items() if v not in (None, "")},
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
}
)
address.insert()
# First created address is the default primary; an explicit flag overrides.
# (Must not key off the loop index — skipped rows would leave no primary.)
if flagged or primary is None:
primary = address
if primary:
# Save (not db.set_value) so Address.validate_preferred_address() clears any
# existing primary address on the party — a raw write would leave two flagged.
primary.is_primary_address = 1
primary.save()
customer.db_set("customer_primary_address", primary.name)
customer.db_set("primary_address", get_address_display(primary.name))
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
linked = frappe.get_all(
"Dynamic Link",
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
pluck="parent",
)
for other in frappe.get_all(
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
):
if other != keep:
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
"""Non-table importable fields of ``doctype`` as complete docfield dicts."""
from frappe.model import display_fieldtypes, no_value_fields
fields = []
for df in frappe.get_meta(doctype).fields:
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
continue
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
continue
field_dict = df.as_dict()
if prefer_plain_label:
field_dict["prefer_plain_label"] = 1
fields.append(field_dict)
return fields
def _contact_docfields() -> list[dict]:
"""Contact fields with plain-label and import-header aliases for common CSV headers."""
fields = _doctype_docfields("Contact", prefer_plain_label=True)
for field in fields:
if field.get("fieldname") == "email_id":
field["import_labels"] = ["Email ID"]
return fields
def _doctype_child_tables(doctype: str) -> list[dict]:
"""``doctype``'s own child tables as schema groups (fieldname, label, fields)."""
return [
{
"fieldname": tf.fieldname,
"label": _(tf.label or tf.fieldname),
"fields": _doctype_docfields(tf.options),
}
for tf in frappe.get_meta(doctype).get_table_fields()
]

View File

@@ -38,6 +38,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -106,7 +107,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-21 18:11:30.134073",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Delivery Schedule Item",

View File

@@ -216,6 +216,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -729,7 +730,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 19:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",

View File

@@ -36,6 +36,7 @@ from erpnext.selling.doctype.sales_order.sales_order import (
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_bin_details
from erpnext.stock.utils import InvalidWarehouseCompany
from erpnext.tests.utils import ERPNextTestSuite
@@ -159,6 +160,38 @@ class TestSalesOrder(ERPNextTestSuite):
)
update_child_qty_rate("Sales Order", trans_item, so.name)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_order_negative_grand_total_blocked_without_setting(self):
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
self.assertRaises(frappe.ValidationError, so.save)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
so.save()
so.submit()
self.assertEqual(so.docstatus, 1)
self.assertTrue(so.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_order_negative_rate_error_links_to_selling_settings(self):
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -10})
so.save()
with self.assertRaises(frappe.ValidationError) as error:
so.submit()
self.assertIn("selling-settings", str(error.exception))
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_negative_rate_setting_does_not_allow_negative_quantity(self):
so = make_sales_order(qty=-1, rate=100, do_not_save=True)
self.assertRaises(frappe.NonNegativeError, so.save)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_sales_order_qty(self):
so = make_sales_order(qty=1, do_not_save=True)
@@ -587,6 +620,116 @@ class TestSalesOrder(ERPNextTestSuite):
self.assertEqual(updated_total, prev_total + 1400)
self.assertNotEqual(updated_total_in_words, prev_total_in_words)
def test_update_child_adding_new_item_with_warehouse(self):
so = make_sales_order(item_code="_Test Item", qty=4)
first_item_of_so = so.get("items")[0]
self.assertNotEqual(first_item_of_so.warehouse, "_Test Warehouse 2 - _TC")
def get_trans_item(warehouse):
return json.dumps(
[
{
"item_code": first_item_of_so.item_code,
"rate": first_item_of_so.rate,
"qty": first_item_of_so.qty,
"docname": first_item_of_so.name,
"warehouse": warehouse,
},
{"item_code": "_Test Item 2", "rate": 200, "qty": 7, "warehouse": warehouse},
]
)
self.assertRaises(
InvalidWarehouseCompany,
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Warehouse 2 - _TC1"),
so.name,
)
self.assertRaisesRegex(
frappe.ValidationError,
"Group node warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Warehouse Group - _TC"),
so.name,
)
if not frappe.db.exists("Warehouse", "_Test Disabled Warehouse - _TC"):
frappe.get_doc(
{
"doctype": "Warehouse",
"warehouse_name": "_Test Disabled Warehouse",
"company": "_Test Company",
"disabled": 1,
}
).insert()
self.assertRaisesRegex(
frappe.ValidationError,
"Disabled Warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Disabled Warehouse - _TC"),
so.name,
)
update_child_qty_rate("Sales Order", get_trans_item("_Test Warehouse 2 - _TC"), so.name)
so.reload()
# the new row picks up the warehouse selected in the dialog
self.assertEqual(so.get("items")[-1].item_code, "_Test Item 2")
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
# existing rows keep theirs, so their reserved qty stays in the same bin
self.assertEqual(so.get("items")[0].warehouse, first_item_of_so.warehouse)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
item_code = make_item("_Test Item Without Default Warehouse", {"is_stock_item": 1}).name
so = make_sales_order(item_code="_Test Item", qty=4)
existing_item = so.get("items")[0]
# a company gets a default warehouse when its warehouses are created
company_default = frappe.db.get_value("Company", so.company, "default_warehouse")
frappe.db.set_value("Company", so.company, "default_warehouse", None)
self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default)
def get_trans_items(warehouse=None):
new_row = {"item_code": item_code, "rate": 200, "qty": 7}
if warehouse:
new_row["warehouse"] = warehouse
return json.dumps(
[
{
"item_code": existing_item.item_code,
"rate": existing_item.rate,
"qty": existing_item.qty,
"docname": existing_item.name,
},
new_row,
]
)
# no default in the Item Master, Item Group, Brand or Company
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_items(),
so.name,
)
update_child_qty_rate("Sales Order", get_trans_items("_Test Warehouse - _TC"), so.name)
so.reload()
self.assertEqual(len(so.get("items")), 2)
self.assertEqual(so.get("items")[0].warehouse, existing_item.warehouse)
self.assertEqual(so.get("items")[-1].item_code, item_code)
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse - _TC")
def test_update_child_removing_item(self):
so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]})
create_dn_against_so(so.name, 2)
@@ -3104,6 +3247,17 @@ class TestSalesOrder(ERPNextTestSuite):
so.save()
self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100)
with self.subTest("floating-point drift in the total is tolerated"):
# 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point
so = make_sales_order(do_not_save=True)
for sales_person, percentage in (
("_Test Sales Person", 10.0),
("_Test Sales Person 1", 58.02),
("_Test Sales Person 2", 31.98),
):
so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage})
so.save()
def test_sales_team_disabled_sales_person_rejected(self):
frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0)
try:

View File

@@ -271,6 +271,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1055,7 +1056,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 20:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -257,6 +257,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -982,7 +983,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

@@ -430,7 +430,7 @@ def notify_customers(delivery_trip: str):
frappe.sendmail(
recipients=contact_info.email_id,
subject=dispatch_template.subject,
message=frappe.render_template(dispatch_template.response, context),
message=frappe.render_template(dispatch_template.response, context, restrict_globals=True),
attachments=get_attachments(stop),
)

View File

@@ -1508,7 +1508,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
"UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1
)
if inverse_match:
return 1 / inverse_match.value
return flt(1 / inverse_match.value, frappe.get_precision("UOM Conversion Factor", "value"))
# This attempts to try and get conversion from intermediate UOM.
# case:
@@ -1528,7 +1528,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
)
if intermediate_match:
return intermediate_match[0].value
return flt(intermediate_match[0].value, frappe.get_precision("UOM Conversion Factor", "value"))
@frappe.whitelist()

View File

@@ -288,51 +288,6 @@ def get_items_based_on_default_supplier(supplier: str):
return supplier_items
@frappe.whitelist()
def make_purchase_order_based_on_supplier(
source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None
):
mr = source_name
supplier_items = get_items_based_on_default_supplier(args.get("supplier"))
def postprocess(source, target_doc):
target_doc.supplier = args.get("supplier")
if getdate(target_doc.schedule_date) < getdate(nowdate()):
target_doc.schedule_date = None
target_doc.set(
"items",
[d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0],
)
set_missing_values(source, target_doc)
target_doc = get_mapped_doc(
"Material Request",
mr,
{
"Material Request": {
"doctype": "Purchase Order",
},
"Material Request Item": {
"doctype": "Purchase Order Item",
"field_map": [
["name", "material_request_item"],
["parent", "material_request"],
["uom", "stock_uom"],
["uom", "uom"],
],
"postprocess": update_item,
"condition": lambda doc: doc.ordered_qty < doc.qty,
},
},
target_doc,
postprocess,
)
return target_doc
@frappe.whitelist()
def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None):
def postprocess(source, target_doc):

View File

@@ -315,7 +315,8 @@
"fieldtype": "Link",
"label": "Work Order",
"options": "Work Order",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "terms_tab",
@@ -376,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-07-30 11:04:31.517204",
"modified": "2026-08-07 10:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",

View File

@@ -273,6 +273,7 @@ class MaterialRequest(BuyingController):
def on_submit(self):
self.update_requested_qty_in_production_plan()
self.update_requested_qty()
self.update_requested_qty_in_work_order()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
if frappe.db.exists("Budget", {"applicable_on_material_request": 1, "docstatus": 1}):
@@ -283,6 +284,20 @@ class MaterialRequest(BuyingController):
def before_submit(self):
self.set_status(update=True)
self.validate_pending_qty_in_work_order()
def validate_pending_qty_in_work_order(self):
if not self.work_order or self.material_request_type != "Material Transfer":
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
incoming = {}
for row in self.items:
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.stock_qty)
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
def before_cancel(self):
# if MRQ is already closed, no point saving the document
@@ -301,6 +316,7 @@ class MaterialRequest(BuyingController):
self.status_can_change(status)
self.set_status(update=True, status=status)
self.update_requested_qty()
self.update_requested_qty_in_work_order()
def status_can_change(self, status):
"""
@@ -330,6 +346,7 @@ class MaterialRequest(BuyingController):
def on_cancel(self):
self.update_requested_qty_in_production_plan(cancel=True)
self.update_requested_qty()
self.update_requested_qty_in_work_order()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
@@ -417,6 +434,19 @@ class MaterialRequest(BuyingController):
update_modified,
)
self.update_requested_qty_in_work_order()
def update_requested_qty_in_work_order(self):
"""Refresh both counters: stop and cancel also flip pick list coverage."""
if not self.work_order or self.material_request_type != "Material Transfer":
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
service = RequiredItemsService(frappe.get_doc("Work Order", self.work_order))
service.update_requested_qty_for_required_items()
service.update_picked_qty_for_required_items()
def update_requested_qty(self, mr_item_rows=None):
"""update requested qty (before ordered_qty is updated)"""
item_wh_list = []

View File

@@ -159,6 +159,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -545,7 +546,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-06 20:47:27.317226",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request Item",

View File

@@ -243,7 +243,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fieldname": "rate",
@@ -349,7 +350,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",

View File

@@ -80,7 +80,8 @@
"fieldname": "work_order",
"fieldtype": "Link",
"label": "Work Order",
"options": "Work Order"
"options": "Work Order",
"search_index": 1
},
{
"fieldname": "locations",
@@ -278,7 +279,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-07-01 14:27:50.617011",
"modified": "2026-08-07 10:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -240,6 +240,45 @@ class PickList(TransactionBase):
def before_submit(self):
self.validate_sales_order()
self.validate_picked_items()
self.validate_pending_qty_in_work_order()
def validate_pending_qty_in_work_order(self):
"""Rows covered by a live material request must stay within that request;
every other row must fit the work order's pending requirement."""
if not self.work_order:
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
live_requests = {}
request_pending = {}
incoming = {}
for row in self.locations:
if row.material_request not in live_requests:
live_requests[row.material_request] = is_live_material_request(row.material_request)
if not (row.material_request_item and live_requests[row.material_request]):
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.picked_qty)
continue
if row.material_request_item not in request_pending:
stock_qty, ordered_qty = frappe.db.get_value(
"Material Request Item", row.material_request_item, ["stock_qty", "ordered_qty"]
)
request_pending[row.material_request_item] = flt(stock_qty) - flt(ordered_qty)
if flt(row.picked_qty - request_pending[row.material_request_item], 6) > 0:
frappe.throw(
_("Row #{0}: picked qty {1} {2} exceeds the pending qty in Material Request {3}.").format(
row.idx, row.picked_qty, row.stock_uom, row.material_request
),
title=_("Exceeds Requested Qty"),
)
request_pending[row.material_request_item] -= flt(row.picked_qty)
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
def validate_sales_order(self):
"""Raises an exception if the `Sales Order` has reserved stock."""
@@ -281,6 +320,7 @@ class PickList(TransactionBase):
self.update_bundle_picked_qty()
self.update_reference_qty()
self.update_sales_order_picking_status()
self.update_picked_qty_in_work_order()
self.update_prevdoc_status()
def validate_expired_batches(self):
@@ -358,6 +398,7 @@ class PickList(TransactionBase):
self.update_bundle_picked_qty()
self.update_reference_qty()
self.update_sales_order_picking_status()
self.update_picked_qty_in_work_order()
self.delink_serial_and_batch_bundle()
self.update_prevdoc_status()
@@ -494,6 +535,15 @@ class PickList(TransactionBase):
for sales_order in sales_orders:
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
def update_picked_qty_in_work_order(self):
if not self.work_order:
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order)
RequiredItemsService(work_order).update_picked_qty_for_required_items()
@frappe.whitelist()
def create_stock_reservation_entries(self, notify: bool = True) -> None:
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
@@ -936,6 +986,15 @@ def update_pick_list_status(pick_list):
if pick_list:
doc = frappe.get_doc("Pick List", pick_list)
doc.run_method("update_status")
doc.update_picked_qty_in_work_order()
def is_live_material_request(material_request):
if not material_request:
return False
docstatus, status = frappe.db.get_value("Material Request", material_request, ["docstatus", "status"])
return docstatus == 1 and status != "Stopped"
def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:

View File

@@ -126,6 +126,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -307,7 +308,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -291,6 +291,7 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"print_width": "100px",
"reqd": 1,
@@ -1144,7 +1145,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-16 15:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",

View File

@@ -106,12 +106,13 @@
"fieldtype": "Float",
"label": "Conversion Factor",
"no_copy": 1,
"precision": "9",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-07-08 09:19:26.711470",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Putaway Rule",

View File

@@ -265,6 +265,9 @@ class QualityInspection(Document):
self.modified,
)
if self.reference_type and self.reference_name:
frappe.get_lazy_doc(self.reference_type, self.reference_name).notify_update()
def inspect_and_set_status(self):
for reading in self.readings:
if not reading.manual_inspection: # dont auto set status if manual

View File

@@ -2,6 +2,7 @@
# See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from frappe.utils import nowdate
@@ -78,6 +79,27 @@ class TestQualityInspection(ERPNextTestSuite):
qa.delete()
dn.delete()
def test_doc_update_published_for_reference_on_submit(self):
"""Submitting a QI publishes doc_update so open reference forms resync their timestamp."""
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
qa = create_quality_inspection(
reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True
)
with patch.object(frappe, "publish_realtime") as publish_realtime:
qa.submit()
reference_updates = [
call
for call in publish_realtime.call_args_list
if call.args and call.args[0] == "doc_update" and call.kwargs.get("docname") == dn.name
]
self.assertEqual(len(reference_updates), 1)
message = reference_updates[0].args[1]
self.assertEqual(message["doctype"], "Delivery Note")
self.assertEqual(message["modified"], frappe.db.get_value("Delivery Note", dn.name, "modified"))
def test_value_based_qi_readings(self):
# Test QI based on acceptance values (Non formula)
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)

View File

@@ -653,6 +653,55 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
# incoming rate after reposting should be 150
self.assertSLEs(se, [{"incoming_rate": 150}])
def test_repost_multi_line_moving_average_return(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
item = self.make_item(properties={"valuation_method": "Moving Average"}).name
warehouse = "_Test Warehouse - _TC"
make_purchase_receipt(item_code=item, qty=100, rate=100, warehouse=warehouse)
pr = make_purchase_receipt(item_code=item, qty=400, rate=200, warehouse=warehouse, do_not_submit=1)
for qty in (100, 300, 100):
pr.append(
"items",
{
"item_code": item,
"warehouse": warehouse,
"qty": qty,
"received_qty": qty,
"rate": 200,
"uom": pr.items[0].uom,
"conversion_factor": 1.0,
},
)
pr.save()
pr.submit()
return_pr = make_return_doc(pr.doctype, pr.name)
return_pr.save()
return_pr.submit()
expected_sles = [
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 600.0},
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 500.0},
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 200.0},
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 100.0},
]
for _ in range(2):
riv = frappe.get_doc(
doctype="Repost Item Valuation",
based_on="Transaction",
voucher_type=pr.doctype,
voucher_no=pr.name,
posting_date=pr.posting_date,
posting_time=pr.posting_time,
)
riv.submit()
self.assertSLEs(return_pr, expected_sles)
def test_remove_attached_file(self):
item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1})

View File

@@ -319,6 +319,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_batch()
self.validate_inspection()
self.validate_fg_completed_qty()
self.validate_job_card_pending_production()
self.validate_difference_account()
self.validate_job_card_item()
self.set_purpose_for_stock_entry()
@@ -1452,23 +1453,15 @@ class StockEntry(StockController, SubcontractingInwardController):
return
precision = self.precision("process_loss_qty")
if self.work_order:
data = frappe.get_all(
"Work Order Operation",
filters={"parent": self.work_order},
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
process_loss_qty = self.get_pending_process_loss_qty()
if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
self.process_loss_qty = flt(process_loss_qty, precision)
frappe.msgprint(
_("The Process Loss Qty has been reset as per the job card's Process Loss Qty"),
alert=True,
)
if data and data[0].process_loss_qty:
process_loss_qty = data[0].process_loss_qty
if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
self.process_loss_qty = flt(process_loss_qty, precision)
frappe.msgprint(
_("The Process Loss Qty has been reset as per the job card's Process Loss Qty"),
alert=True,
)
if not self.process_loss_percentage and not self.process_loss_qty:
self.process_loss_percentage = frappe.get_cached_value(
"BOM", self.bom_no, "process_loss_percentage"
@@ -1483,6 +1476,60 @@ class StockEntry(StockController, SubcontractingInwardController):
(flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100
)
def validate_job_card_pending_production(self):
"""A draft created before other entries were submitted must not book more than the job
card still has left; without this, a stale draft over-produces the finished good."""
if self.purpose != "Manufacture" or not self.job_card:
return
if self._action == "update_after_submit":
return
job_card = frappe.get_doc("Job Card", self.job_card)
if job_card.is_corrective_job_card or job_card.is_subcontracted:
return
precision = frappe.get_precision("Stock Entry Detail", "qty")
pending_qty = flt(
flt(job_card.get_qty_to_produce())
- flt(job_card.manufactured_qty)
- flt(job_card.get_consumed_process_loss()),
precision,
)
finished_qty = flt(sum(flt(d.transfer_qty) for d in self.items if d.is_finished_item), precision)
entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision)
if entry_qty > pending_qty:
uom = job_card.stock_uom
frappe.throw(
_(
"The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first."
).format(
frappe.bold(self.job_card),
frappe.bold(f"{pending_qty} {uom}"),
frappe.bold(f"{entry_qty} {uom}"),
f"{finished_qty} {uom}",
f"{flt(self.process_loss_qty, precision)} {uom}",
)
)
def get_pending_process_loss_qty(self):
"""Loss this entry should still book: the job card's unbooked loss when the entry
belongs to one, else the largest operation loss on the work order (legacy flow)."""
if self.job_card:
job_card = frappe.get_doc("Job Card", self.job_card)
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
if self.work_order:
data = frappe.get_all(
"Work Order Operation",
filters={"parent": self.work_order},
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
)
return flt(data[0].process_loss_qty) if data else 0
return 0
def set_work_order_details(self):
if self.work_order:
# common validations

View File

@@ -257,6 +257,7 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -700,7 +701,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -126,6 +126,7 @@ class ManufactureEntry:
if backflush_based_on != "BOM":
available_serial_batches = self.get_transferred_serial_batches()
production_share = self.get_production_share()
for item_code, _dict in item_dict.items():
_dict.s_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
_dict.t_warehouse = ""
@@ -140,11 +141,29 @@ class ManufactureEntry:
_dict.qty = calculated_qty
self.update_available_serial_batches(_dict, available_serial_batches)
elif self.skip_material_transfer:
set_previous_operation_serial_batch(self.stock_entry, _dict)
else:
remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0)
_dict.qty = min(flt(_dict.qty) * production_share, remaining_qty)
if not _dict.qty:
continue
if self.skip_material_transfer:
set_previous_operation_serial_batch(self.stock_entry, _dict)
self.stock_entry.append("items", _dict)
def get_production_share(self):
"""Fraction of the job card's production this entry accounts for; raw materials are
generated proportionally so several partial entries never consume more than required."""
for_quantity, pending_qty = frappe.db.get_value(
"Job Card", self.job_card, ["for_quantity", "pending_qty"]
)
qty_to_produce = flt(for_quantity) - flt(pending_qty)
if not qty_to_produce:
return 1
return min(flt(self.for_quantity) / qty_to_produce, 1)
def parse_available_serial_batches(self, item_dict, available_serial_batches):
key = (item_dict.item_code, item_dict.from_warehouse)
if key not in available_serial_batches:

View File

@@ -28,7 +28,8 @@
"label": "Conversion Factor",
"non_negative": 1,
"oldfieldname": "conversion_factor",
"oldfieldtype": "Float"
"oldfieldtype": "Float",
"precision": "9"
},
{
"fieldname": "column_break_nmeg",
@@ -38,7 +39,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-11 23:02:54.800673",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "UOM Conversion Detail",

View File

@@ -7,6 +7,8 @@ import frappe
from frappe import _
from frappe.utils import cint, flt, get_link_to_form, parse_json
from erpnext.stock.utils import get_valuation_method
SLE_FIELDS = (
"name",
"posting_date",
@@ -53,6 +55,9 @@ def add_invariant_check_fields(sles, filters):
balance_qty = 0.0
balance_stock_value = 0.0
company = frappe.get_cached_value("Warehouse", filters.warehouse, "company")
valuation_method = get_valuation_method(filters.item_code, company)
incorrect_idx = None
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
@@ -90,7 +95,7 @@ def add_invariant_check_fields(sles, filters):
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if maintains_fifo_queue(sle):
if maintains_fifo_queue(sle, valuation_method):
add_fifo_fields(sle, sles[idx - 1] if idx else None)
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
@@ -104,8 +109,10 @@ def add_invariant_check_fields(sles, filters):
return sles
def maintains_fifo_queue(sle):
# no queue is maintained for serialized/batchwise-valued stock
def maintains_fifo_queue(sle, valuation_method):
if valuation_method == "Moving Average":
return False
return not (
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
)
@@ -138,6 +145,8 @@ def is_sle_has_correct_data(sle, float_precision, currency_precision):
return (
flt(sle.difference_in_qty, float_precision) == 0.0
and flt(sle.diff_value_diff, currency_precision) == 0.0
and flt(sle.fifo_qty_diff, float_precision) == 0.0
and flt(sle.fifo_value_diff, currency_precision) == 0.0
)

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
import frappe
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -59,6 +61,34 @@ class TestStockLedgerInvariantCheck(ERPNextTestSuite):
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
self.assertEqual(data[-1].name, sle.name)
def test_show_incorrect_entries_catches_queue_mismatch(self):
item = self.make_movements()
sle = frappe.get_last_doc(
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
)
tampered_queue = json.dumps([[sle.qty_after_transaction + 5, 100]])
frappe.db.set_value("Stock Ledger Entry", sle.name, "stock_queue", tampered_queue)
data = self.run_report(item_code=item, show_incorrect_entries=1)
self.assertEqual(len(data), 2)
self.assertEqual(data[-1].name, sle.name)
def test_moving_average_item_skips_fifo_queue_checks(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(properties={"valuation_method": "Moving Average"}).name
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4)
data = self.run_report(item_code=item)
self.assertTrue(data)
for row in data:
self.assertIsNone(row.fifo_qty_diff)
self.assertIsNone(row.fifo_value_diff)
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
def test_batch_item_skips_fifo_queue_checks(self):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -2,6 +2,30 @@
// For license information, please see license.txt
frappe.query_reports["Stock Qty vs Serial No Count"] = {
onload: function (report) {
report.page.add_inner_button(__("Sync Serial No Status"), () => {
const warehouse = report.get_filter_value("warehouse");
if (!warehouse) {
frappe.msgprint(__("Please select a warehouse first."));
return;
}
frappe.confirm(
__(
"This will update the warehouse and status of Serial Nos counted in {0} to match the stock ledger. Continue?",
[warehouse.bold()]
),
() => {
frappe.call({
method: "erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count.sync_serial_no_status",
args: { warehouse: warehouse },
freeze: true,
});
}
);
});
},
filters: [
{
fieldname: "company",

View File

@@ -4,6 +4,12 @@
import frappe
from frappe import _
from frappe.query_builder import Order
from frappe.query_builder.functions import Coalesce
from frappe.utils import cstr, flt
from pypika import analytics as an
from erpnext.stock.serial_batch_bundle import get_serial_no_status
def execute(filters=None):
@@ -77,3 +83,172 @@ def get_data(warehouse, show_disabled_items):
data.append(row)
return data
SYNC_CHUNK_SIZE = 1000
@frappe.whitelist(methods=["POST"])
def sync_serial_no_status(warehouse: str, item_code: str | None = None):
if not frappe.has_permission("Serial No", "write"):
frappe.throw(_("Not permitted to update Serial No"), frappe.PermissionError)
warehouse = cstr(warehouse)
item_code = cstr(item_code) if item_code else None
if not frappe.db.exists("Warehouse", warehouse):
frappe.throw(_("Warehouse {0} does not exist").format(warehouse))
if item_code and not frappe.db.exists("Item", item_code):
frappe.throw(_("Item {0} does not exist").format(item_code))
frappe.enqueue(
sync_serial_no_status_for_warehouse,
queue="long",
warehouse=warehouse,
item_code=item_code,
)
frappe.msgprint(
_("Serial No status sync has been queued. Reload the report after a few minutes."),
alert=True,
)
def sync_serial_no_status_for_warehouse(warehouse, item_code=None):
filters = {"has_serial_no": 1}
if item_code:
filters["name"] = item_code
for item in frappe.get_all("Item", filters=filters, pluck="name"):
sync_serial_no_status_for_item(item, warehouse)
def sync_serial_no_status_for_item(item_code, warehouse):
"""Correct Serial No records this report counts in the warehouse but whose last
stock ledger movement says the stock left it. Reposting rebuilds qty and valuation
from the ledger but never rewrites Serial No warehouse/status, so records orphaned
by cancelled or amended vouchers keep inflating the serial count."""
serial_nos = frappe.get_all(
"Serial No",
filters={"item_code": item_code, "warehouse": warehouse, "status": ("in", ["Active", "Expired"])},
pluck="name",
)
if not serial_nos:
return
last_moves = get_last_ledger_moves(item_code, serial_nos)
for serial_no in serial_nos:
row = last_moves.get(serial_no)
if row and flt(row.qty) > 0 and row.warehouse == warehouse:
continue
set_serial_no_state_from_ledger(serial_no, row)
def set_serial_no_state_from_ledger(serial_no, row):
if not row:
frappe.db.set_value(
"Serial No", serial_no, {"warehouse": None, "status": "Inactive"}, update_modified=False
)
return
status = get_serial_no_status(
frappe._dict(
actual_qty=flt(row.qty),
warehouse=row.warehouse,
voucher_type=row.voucher_type,
voucher_no=row.voucher_no,
is_cancelled=0,
)
)
warehouse = row.warehouse if status == "Active" else None
frappe.db.set_value(
"Serial No", serial_no, {"warehouse": warehouse, "status": status}, update_modified=False
)
def get_last_ledger_moves(item_code, serial_nos):
last_moves = get_last_bundle_moves(item_code, serial_nos)
if missing := [serial_no for serial_no in serial_nos if serial_no not in last_moves]:
set_legacy_last_moves(item_code, missing, last_moves)
return last_moves
def get_last_bundle_moves(item_code, serial_nos):
last_moves = {}
for start in range(0, len(serial_nos), SYNC_CHUNK_SIZE):
for row in get_last_bundle_moves_chunk(item_code, serial_nos[start : start + SYNC_CHUNK_SIZE]):
last_moves[row.serial_no] = row
return last_moves
def get_last_bundle_moves_chunk(item_code, serial_nos):
"""A bundle can be created much before its Stock Ledger Entry, so same-posting-datetime
ties are broken on the creation of the bundle's own SLE. The SLE join also keeps only
real stock movements - reservation bundles (Pick List) carry no SLE."""
entry = frappe.qb.DocType("Serial and Batch Entry")
bundle = frappe.qb.DocType("Serial and Batch Bundle")
sle = frappe.qb.DocType("Stock Ledger Entry")
row_number = (
an.RowNumber()
.over(entry.serial_no)
.orderby(Coalesce(entry.posting_datetime, bundle.posting_datetime), order=Order.desc)
.orderby(sle.creation, order=Order.desc)
)
ranked = (
frappe.qb.from_(entry)
.inner_join(bundle)
.on(entry.parent == bundle.name)
.inner_join(sle)
.on(sle.serial_and_batch_bundle == bundle.name)
.select(
entry.serial_no,
entry.qty,
Coalesce(entry.warehouse, bundle.warehouse).as_("warehouse"),
bundle.voucher_type,
bundle.voucher_no,
row_number.as_("row_no"),
)
.where(
(bundle.docstatus == 1)
& (Coalesce(bundle.is_cancelled, 0) == 0)
& (sle.is_cancelled == 0)
& (bundle.item_code == item_code)
& (entry.serial_no.isin(serial_nos))
)
).as_("ranked")
return (
frappe.qb.from_(ranked)
.select(ranked.serial_no, ranked.qty, ranked.warehouse, ranked.voucher_type, ranked.voucher_no)
.where(ranked.row_no == 1)
.run(as_dict=True)
)
def set_legacy_last_moves(item_code, serial_nos, last_moves):
"""Movements posted before Serial and Batch Bundle exist only as newline-separated
text on Stock Ledger Entry."""
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
pending = set(serial_nos)
rows = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "is_cancelled": 0, "serial_no": ("is", "set")},
fields=["serial_no", "actual_qty", "warehouse", "voucher_type", "voucher_no"],
order_by="posting_datetime asc, creation asc",
)
for row in rows:
qty = 1 if flt(row.actual_qty) > 0 else -1
for serial_no in get_serial_nos(row.serial_no):
if serial_no in pending:
last_moves[serial_no] = frappe._dict(
qty=qty,
warehouse=row.warehouse,
voucher_type=row.voucher_type,
voucher_no=row.voucher_no,
)

View File

@@ -46,3 +46,35 @@ class TestStockQtyVsSerialNoCount(ERPNextTestSuite):
}
)
)
def test_sync_serial_no_status(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count import (
sync_serial_no_status_for_warehouse,
)
item = "_Test Serialized Item With Series"
warehouse = "Stores - _TC"
se = make_stock_entry(item_code=item, to_warehouse=warehouse, qty=2, rate=100)
serial_no = frappe.get_all(
"Serial and Batch Entry",
{"parent": se.items[0].serial_and_batch_bundle},
pluck="serial_no",
)[0]
create_delivery_note(
item_code=item,
warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
)
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered")
frappe.db.set_value("Serial No", serial_no, {"status": "Active", "warehouse": warehouse})
sync_serial_no_status_for_warehouse(warehouse, item_code=item)
details = frappe.db.get_value("Serial No", serial_no, ["status", "warehouse"], as_dict=True)
self.assertEqual(details.status, "Delivered")
self.assertFalse(details.warehouse)

View File

@@ -38,7 +38,6 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
from erpnext.stock.utils import (
get_combine_datetime,
get_incoming_outgoing_rate_for_cancel,
get_incoming_rate,
get_or_make_bin,
get_serial_nos_data,
get_stock_balance,
@@ -1460,23 +1459,7 @@ class update_entries_after:
and not sle.get("batch_no")
and not sle.get("serial_and_batch_bundle")
):
rate = get_incoming_rate(
{
"item_code": sle.item_code,
"warehouse": sle.warehouse,
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
"qty": sle.actual_qty,
"serial_no": sle.get("serial_no"),
"batch_no": sle.get("batch_no"),
"serial_and_batch_bundle": sle.get("serial_and_batch_bundle"),
"company": sle.company,
"voucher_type": sle.voucher_type,
"voucher_no": sle.voucher_no,
"allow_zero_valuation": self.allow_zero_rate,
"sle": sle.name,
}
)
rate = self.get_moving_average_rate_for_return(sle)
if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]:
rate = get_rate_for_return(
@@ -1544,6 +1527,38 @@ class update_entries_after:
return rate
def get_moving_average_rate_for_return(self, sle):
"""Rate just before this entry, taken from the in-memory running state so a
multi-line return never reads a sibling row of its own voucher."""
rate = flt(self.wh_data.valuation_rate)
if rate:
return rate
previous_sle = get_previous_sle_of_current_voucher(
frappe._dict(
item_code=sle.item_code,
warehouse=sle.warehouse,
posting_date=sle.posting_date,
posting_time=sle.posting_time,
voucher_no=sle.voucher_no,
),
exclude_current_voucher=True,
)
rate = previous_sle.get("valuation_rate")
if rate is None:
rate = get_valuation_rate(
sle.item_code,
sle.warehouse,
sle.voucher_type,
sle.voucher_no,
self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company),
company=sle.company,
)
return flt(rate)
def update_outgoing_rate_on_transaction(self, sle):
"""
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return

View File

@@ -107,6 +107,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -128,7 +129,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-27 13:10:45.904619",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting BOM",

View File

@@ -87,6 +87,7 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -186,7 +187,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-18 18:04:04.204651",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Item",

View File

@@ -174,6 +174,7 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -425,7 +426,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-27 23:03:36.436504",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order Item",

View File

@@ -63,6 +63,7 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -176,7 +177,7 @@
"hide_toolbar": 1,
"istable": 1,
"links": [],
"modified": "2025-10-30 16:00:43.379828",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order Supplied Item",

View File

@@ -205,6 +205,7 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -657,7 +658,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",

View File

@@ -134,6 +134,7 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -275,7 +276,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Supplied Item",