mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 08:23:01 +00:00
Compare commits
106 Commits
v15.109.1
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3e3dfd83 | ||
|
|
316bb13853 | ||
|
|
18ca96c36b | ||
|
|
95f46dfc01 | ||
|
|
0e64acb0fa | ||
|
|
a5c23a3d16 | ||
|
|
2f5b93e308 | ||
|
|
aeed6a459f | ||
|
|
c0f56cd284 | ||
|
|
a94e362b8c | ||
|
|
a121048f9b | ||
|
|
cedd0a1903 | ||
|
|
8bccc25b1a | ||
|
|
335173c5ba | ||
|
|
ba19a24526 | ||
|
|
2c4b89d1df | ||
|
|
14bafe9dfd | ||
|
|
77d9849d16 | ||
|
|
09453f883b | ||
|
|
dc1fb3f804 | ||
|
|
07b61113af | ||
|
|
2d1c0dcb53 | ||
|
|
45b232d369 | ||
|
|
7852ea65af | ||
|
|
999cfef619 | ||
|
|
2d0e3fd9af | ||
|
|
4c9c2911a6 | ||
|
|
e0a0b3eafc | ||
|
|
8c9168595f | ||
|
|
e7eaa87a77 | ||
|
|
ffb36cff78 | ||
|
|
4fadc4aab6 | ||
|
|
bde46cffd0 | ||
|
|
08f3cf98f9 | ||
|
|
55b0715310 | ||
|
|
c15012cd51 | ||
|
|
e8e0514a30 | ||
|
|
ddaf75a60d | ||
|
|
013bd1a566 | ||
|
|
e8267e3237 | ||
|
|
d5477b096d | ||
|
|
6d3f9d3c6f | ||
|
|
f65b56d73f | ||
|
|
271ddb6add | ||
|
|
9095c5a3c2 | ||
|
|
0a7c3581da | ||
|
|
611849f953 | ||
|
|
5a80de43fa | ||
|
|
4772799db2 | ||
|
|
ccbca57420 | ||
|
|
d6b2fb2f96 | ||
|
|
a6b7142c18 | ||
|
|
6796617921 | ||
|
|
c65d768020 | ||
|
|
7200c22890 | ||
|
|
aa94c3ff22 | ||
|
|
87c6ad4f85 | ||
|
|
35b4ada3e2 | ||
|
|
ad511b80c0 | ||
|
|
ad55c7c372 | ||
|
|
689a3f50ae | ||
|
|
96bd97dd6d | ||
|
|
897722c35f | ||
|
|
54cbc91166 | ||
|
|
ecf9aa146c | ||
|
|
42e2fd5fc9 | ||
|
|
10664b7b95 | ||
|
|
1980307048 | ||
|
|
13eeddd1f6 | ||
|
|
dc08b615f1 | ||
|
|
ce94f4fd11 | ||
|
|
e314d0cfc5 | ||
|
|
741216d3eb | ||
|
|
94e15ae9ef | ||
|
|
4a6af25d11 | ||
|
|
c7fbc133e6 | ||
|
|
e5aa45cf0d | ||
|
|
3e3689d938 | ||
|
|
d0fc3f029f | ||
|
|
a9cfa22199 | ||
|
|
1238aeb30a | ||
|
|
eebb37f9fd | ||
|
|
f8a123e79d | ||
|
|
1c5220b86f | ||
|
|
779f1b6104 | ||
|
|
c2063c4707 | ||
|
|
e429e608c2 | ||
|
|
75d00ef173 | ||
|
|
94fd15e550 | ||
|
|
03532624b8 | ||
|
|
338feb31e1 | ||
|
|
2a805e090c | ||
|
|
2a12ae1afe | ||
|
|
715ca39abc | ||
|
|
cad14ac3e6 | ||
|
|
2a52ea6850 | ||
|
|
a2d924c48f | ||
|
|
3ad39c987b | ||
|
|
5c392d6123 | ||
|
|
ad6e3a45d2 | ||
|
|
4669ff295f | ||
|
|
264433b23d | ||
|
|
41bf2f32fd | ||
|
|
a6d4bc5c86 | ||
|
|
93dcba40ec | ||
|
|
067c23f20e |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.109.1"
|
||||
__version__ = "15.111.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -517,6 +517,7 @@ def get_account_autoname(account_number, account_name, company):
|
||||
def update_account_number(name, account_name, account_number=None, from_descendant=False):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
account.check_permission("write")
|
||||
if not account:
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"country_code": "nz",
|
||||
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||
"disabled": "No",
|
||||
"tree": {
|
||||
"Application of Funds (Assets)": {
|
||||
"Current Assets": {
|
||||
"Bank Accounts": {
|
||||
"Business Transaction Account": {
|
||||
"account_number": "11011",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Business Savings Account": {
|
||||
"account_number": "11012",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"account_number": "11010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Cash on Hand": {
|
||||
"account_number": "11020",
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Accounts Receivable": {
|
||||
"Debtors": {
|
||||
"account_number": "11210",
|
||||
"account_type": "Receivable"
|
||||
},
|
||||
"Provision for Doubtful Debts": {
|
||||
"account_number": "11220"
|
||||
},
|
||||
"account_number": "11200",
|
||||
"is_group": 1
|
||||
},
|
||||
"Inventory": {
|
||||
"Stock on Hand": {
|
||||
"account_number": "11311",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Work In Progress": {
|
||||
"account_number": "11312",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"account_number": "11310",
|
||||
"account_type": "Stock",
|
||||
"is_group": 1
|
||||
},
|
||||
"Prepayments": {
|
||||
"Prepayments": {
|
||||
"account_number": "11411"
|
||||
},
|
||||
"Supplier Advances": {
|
||||
"account_number": "11412"
|
||||
},
|
||||
"Deferred Expense": {
|
||||
"account_number": "11413"
|
||||
},
|
||||
"account_number": "11410",
|
||||
"is_group": 1
|
||||
},
|
||||
"GST Receivable": {
|
||||
"account_number": "11510",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Receivable": {
|
||||
"account_number": "11520",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "11000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Fixed Assets": {
|
||||
"Plant & Equipment": {
|
||||
"Plant & Equipment": {
|
||||
"account_number": "16011",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Plant & Equipment": {
|
||||
"account_number": "16012",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Motor Vehicles": {
|
||||
"Motor Vehicles": {
|
||||
"account_number": "16021",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Motor Vehicles": {
|
||||
"account_number": "16022",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Office Equipment": {
|
||||
"Office Equipment": {
|
||||
"account_number": "16031",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Office Equipment": {
|
||||
"account_number": "16032",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16030",
|
||||
"is_group": 1
|
||||
},
|
||||
"Buildings": {
|
||||
"Buildings": {
|
||||
"account_number": "16041",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Buildings": {
|
||||
"account_number": "16042",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16040",
|
||||
"is_group": 1
|
||||
},
|
||||
"Computer Equipment": {
|
||||
"Computer Equipment": {
|
||||
"account_number": "16051",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Computer Equipment": {
|
||||
"account_number": "16052",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16050",
|
||||
"is_group": 1
|
||||
},
|
||||
"Capital Work in Progress": {
|
||||
"account_number": "16090",
|
||||
"account_type": "Capital Work in Progress"
|
||||
},
|
||||
"account_number": "16000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "10000",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Source of Funds (Liabilities)": {
|
||||
"Current Liabilities": {
|
||||
"Accounts Payable": {
|
||||
"Creditors": {
|
||||
"account_number": "21010",
|
||||
"account_type": "Payable"
|
||||
},
|
||||
"account_number": "21000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Goods Received Not Invoiced": {
|
||||
"account_number": "21100",
|
||||
"account_type": "Stock Received But Not Billed"
|
||||
},
|
||||
"Asset Received Not Invoiced": {
|
||||
"account_number": "21110",
|
||||
"account_type": "Asset Received But Not Billed"
|
||||
},
|
||||
"Service Received Not Invoiced": {
|
||||
"account_number": "21120",
|
||||
"account_type": "Service Received But Not Billed"
|
||||
},
|
||||
"Accrued Expenses": {
|
||||
"account_number": "21200"
|
||||
},
|
||||
"Wages Payable": {
|
||||
"account_number": "21300"
|
||||
},
|
||||
"PAYE Payable": {
|
||||
"account_number": "22010"
|
||||
},
|
||||
"KiwiSaver Payable": {
|
||||
"account_number": "22020"
|
||||
},
|
||||
"ACC Payable": {
|
||||
"account_number": "22030"
|
||||
},
|
||||
"Credit Cards": {
|
||||
"Business Credit Card": {
|
||||
"account_number": "22110"
|
||||
},
|
||||
"account_number": "22100",
|
||||
"is_group": 1
|
||||
},
|
||||
"Customer Advances": {
|
||||
"account_number": "22200"
|
||||
},
|
||||
"Deferred Revenue": {
|
||||
"account_number": "22210"
|
||||
},
|
||||
"Provisional Account": {
|
||||
"account_number": "22220"
|
||||
},
|
||||
"Tax Liabilities": {
|
||||
"GST Payable": {
|
||||
"account_number": "22310",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"GST Suspense": {
|
||||
"account_number": "22320",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"FBT Payable": {
|
||||
"account_number": "22330",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Payable": {
|
||||
"account_number": "22340",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "22300",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "21500",
|
||||
"is_group": 1
|
||||
},
|
||||
"Non-Current Liabilities": {
|
||||
"Bank Loans": {
|
||||
"Bank Loan": {
|
||||
"account_number": "25011"
|
||||
},
|
||||
"account_number": "25010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Lease Liabilities": {
|
||||
"Lease Liability": {
|
||||
"account_number": "25021"
|
||||
},
|
||||
"account_number": "25020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Shareholder Loans": {
|
||||
"Shareholder Loan": {
|
||||
"account_number": "25031"
|
||||
},
|
||||
"account_number": "25030",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "25000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "20000",
|
||||
"root_type": "Liability"
|
||||
},
|
||||
"Equity": {
|
||||
"Share Capital": {
|
||||
"account_number": "31010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Drawings": {
|
||||
"account_number": "31020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Current Year Earnings": {
|
||||
"account_number": "35010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Retained Earnings": {
|
||||
"account_number": "35020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"account_number": "30000",
|
||||
"root_type": "Equity"
|
||||
},
|
||||
"Income": {
|
||||
"Sales": {
|
||||
"account_number": "41010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Other Income": {
|
||||
"Interest Income": {
|
||||
"account_number": "47010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Rounding Gain/Loss": {
|
||||
"account_number": "47020",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Foreign Exchange Gain": {
|
||||
"account_number": "47030",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"account_number": "47000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "40000",
|
||||
"root_type": "Income"
|
||||
},
|
||||
"Expenses": {
|
||||
"Cost of Goods Sold": {
|
||||
"Purchases": {
|
||||
"account_number": "51010",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
},
|
||||
"Freight Inwards": {
|
||||
"account_number": "51020",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Duty and Landing Costs": {
|
||||
"account_number": "51030",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "51040",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Stock Write Off": {
|
||||
"account_number": "51050",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"account_number": "51000",
|
||||
"account_type": "Cost of Goods Sold",
|
||||
"is_group": 1
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"Wages & Salaries": {
|
||||
"account_number": "61010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"KiwiSaver Employer Contribution": {
|
||||
"account_number": "61020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"ACC Levies": {
|
||||
"account_number": "61030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rent": {
|
||||
"account_number": "65010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Power": {
|
||||
"account_number": "65020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Telephone": {
|
||||
"account_number": "66010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Insurance": {
|
||||
"account_number": "64010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Accounting Fees": {
|
||||
"account_number": "64020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Legal Fees": {
|
||||
"account_number": "64030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Advertising and Marketing": {
|
||||
"account_number": "65030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Repairs and Maintenance": {
|
||||
"account_number": "65040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Freight and Courier": {
|
||||
"account_number": "65050",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Operating Costs": {
|
||||
"account_number": "65060",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "60000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Depreciation and Amortisation": {
|
||||
"Depreciation - Plant & Equipment": {
|
||||
"account_number": "62010",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Motor Vehicles": {
|
||||
"account_number": "62020",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Office Equipment": {
|
||||
"account_number": "62030",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Computer Equipment": {
|
||||
"account_number": "62040",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"account_number": "62000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Finance Costs": {
|
||||
"Bank Charges": {
|
||||
"account_number": "67010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Interest Expense": {
|
||||
"account_number": "67020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rounding Off": {
|
||||
"account_number": "67030",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Payment Discounts": {
|
||||
"account_number": "67040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "67000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Income Tax Expense": {
|
||||
"account_number": "81010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Foreign Exchange": {
|
||||
"Exchange Gain/Loss": {
|
||||
"account_number": "82010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Unrealized Exchange Gain/Loss": {
|
||||
"account_number": "82020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "82000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Bad Debts": {
|
||||
"account_number": "83010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "83020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Gain/Loss on Asset Disposal": {
|
||||
"account_number": "83030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Expenses Included In Asset Valuation": {
|
||||
"account_number": "84010",
|
||||
"account_type": "Expenses Included In Asset Valuation"
|
||||
},
|
||||
"account_number": "50000",
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:Bank Statement Import on {creation}",
|
||||
"beta": 1,
|
||||
"creation": "2019-08-04 14:16:08.318714",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -211,10 +210,11 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-25 17:32:07.658250",
|
||||
"modified": "2026-05-30 20:51:10.353723",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -230,7 +230,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
if (frappe.user.has_role("System Manager")) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
}
|
||||
|
||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"actions": [],
|
||||
"allow_events_in_timeline": 1,
|
||||
"autoname": "naming_series:",
|
||||
"beta": 1,
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
@@ -400,7 +399,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-26 13:46:07.760867",
|
||||
"modified": "2026-05-30 20:40:30.851842",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning",
|
||||
@@ -449,9 +448,10 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "customer_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2019-12-04 04:59:08.003664",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -107,7 +106,7 @@
|
||||
"link_fieldname": "dunning_type"
|
||||
}
|
||||
],
|
||||
"modified": "2021-11-13 00:25:35.659283",
|
||||
"modified": "2026-05-30 20:40:09.952533",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning Type",
|
||||
@@ -151,7 +150,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -378,15 +378,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
|
||||
accounts_add(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
row.exchange_rate = 1;
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
if (!row.exchange_rate) row.exchange_rate = 1;
|
||||
if (!row.account) {
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// set difference
|
||||
if (doc.difference) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_copy": 1,
|
||||
"beta": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -64,10 +64,10 @@
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -82,7 +82,8 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"issingle": 1,
|
||||
"modified": "2022-01-04 15:25:06.053187",
|
||||
"links": [],
|
||||
"modified": "2026-05-30 20:43:36.282738",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool",
|
||||
@@ -99,7 +100,9 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
create_missing_party: DF.Check
|
||||
invoice_type: DF.Literal["Sales", "Purchase"]
|
||||
invoices: DF.Table[OpeningInvoiceCreationToolItem]
|
||||
project: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
def onload(self):
|
||||
|
||||
@@ -1773,6 +1773,35 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
before_cancel: function (frm) {
|
||||
return new Promise((resolve, reject) => {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||
args: { payment_entry: frm.doc.name },
|
||||
callback: function (r) {
|
||||
const linked = r.message || [];
|
||||
if (!linked.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const bt_links = linked
|
||||
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||
.join(", ");
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||
[bt_links]
|
||||
),
|
||||
() => resolve(),
|
||||
() => reject(),
|
||||
__("Yes"),
|
||||
__("No")
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Payment Entry Reference", {
|
||||
|
||||
@@ -2279,6 +2279,9 @@ def get_outstanding_reference_documents(args, validate=False):
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
|
||||
if args.get("party_type") and args.get("party"):
|
||||
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||
|
||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||
args["get_outstanding_invoices"] = True
|
||||
|
||||
@@ -2788,7 +2791,8 @@ def get_reference_details(
|
||||
):
|
||||
total_amount = outstanding_amount = exchange_rate = account = None
|
||||
|
||||
ref_doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||
|
||||
# Only applies for Reverse Payment Entries
|
||||
@@ -3599,3 +3603,16 @@ def make_payment_order(source_name, target_doc=None):
|
||||
@erpnext.allow_regional
|
||||
def add_regional_gl_entries(gl_entries, doc):
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||
return frappe.get_all(
|
||||
"Bank Transaction Payments",
|
||||
filters={
|
||||
"payment_document": "Payment Entry",
|
||||
"payment_entry": payment_entry,
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
|
||||
@@ -202,15 +202,14 @@ class POSProfile(Document):
|
||||
def set_defaults(self, include_current_pos=True):
|
||||
frappe.defaults.clear_default("is_pos")
|
||||
|
||||
if not include_current_pos:
|
||||
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
|
||||
else:
|
||||
condition = " where pfu.default = 1 "
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_view_users = frappe.db.sql_list(
|
||||
f"""select pfu.user
|
||||
from `tabPOS Profile User` as pfu {condition}"""
|
||||
)
|
||||
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
|
||||
|
||||
if not include_current_pos:
|
||||
query = query.where(pfu.name != self.name)
|
||||
|
||||
pos_view_users = query.run(as_list=1, pluck=True)
|
||||
|
||||
for user in pos_view_users:
|
||||
if user:
|
||||
|
||||
@@ -151,13 +151,13 @@
|
||||
"label": "Default Advance Account",
|
||||
"mandatory_depends_on": "doc.party_type",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
"reqd": 0
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-08 08:22:14.798085",
|
||||
"modified": "2026-05-16 11:43:12.758685",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation",
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
|
||||
bank_cash_account: DF.Link | None
|
||||
company: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
default_advance_account: DF.Link
|
||||
default_advance_account: DF.Link | None
|
||||
error_log: DF.LongText | None
|
||||
from_invoice_date: DF.Date | None
|
||||
from_payment_date: DF.Date | None
|
||||
@@ -215,10 +215,7 @@ def trigger_reconciliation_for_queued_docs():
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||
|
||||
def get_filters_as_tuple(fields, doc):
|
||||
filters = ()
|
||||
for x in fields:
|
||||
filters += tuple(doc.get(x))
|
||||
return filters
|
||||
return tuple(doc.get(x) or "" for x in fields)
|
||||
|
||||
for x in all_queued:
|
||||
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Process-PCV-{###}",
|
||||
"creation": "2025-09-25 15:44:03.534699",
|
||||
"doctype": "DocType",
|
||||
@@ -7,11 +8,13 @@
|
||||
"field_order": [
|
||||
"parent_pcv",
|
||||
"status",
|
||||
"amended_from",
|
||||
"section_normal_balances",
|
||||
"p_l_closing_balance",
|
||||
"normal_balances",
|
||||
"bs_closing_balance",
|
||||
"z_opening_balances",
|
||||
"amended_from"
|
||||
"normal_balances",
|
||||
"section_opening_balances",
|
||||
"z_opening_balances"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -64,17 +67,27 @@
|
||||
"fieldname": "bs_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"label": "Balance Sheet Closing Balance"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_normal_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Normal Balances"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_opening_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Opening Balances"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 11:40:24.996403",
|
||||
"modified": "2026-06-01 12:16:37.374412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher",
|
||||
"naming_rule": "Expression",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -559,6 +559,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
|
||||
if parentfield == "z_opening_balances":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
else:
|
||||
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||
query = query.where(gle.is_opening.eq("No"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
// render
|
||||
frappe.listview_settings["Process Period Closing Voucher"] = {
|
||||
add_fields: ["status"],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Queued: "blue",
|
||||
Running: "orange",
|
||||
Paused: "gray",
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
};
|
||||
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -99,6 +99,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -548,6 +549,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_auto_email():
|
||||
frappe.has_permission("Process Statement Of Accounts", throw=True)
|
||||
selected = frappe.get_list(
|
||||
"Process Statement Of Accounts",
|
||||
filters={"enable_auto_email": 1},
|
||||
|
||||
@@ -608,6 +608,25 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_account", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
report_type: "Profit and Loss",
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_cost_center", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -287,6 +287,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_expense_account()
|
||||
self.set_against_expense_account()
|
||||
self.validate_write_off_account()
|
||||
self.validate_write_off_cost_center()
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
@@ -633,15 +634,16 @@ class PurchaseInvoice(BuyingController):
|
||||
throw(msg, title=_("Mandatory Purchase Order"))
|
||||
|
||||
def pr_required(self):
|
||||
stock_items = self.get_stock_items()
|
||||
if frappe.db.get_value("Buying Settings", None, "pr_required") == "Yes":
|
||||
if frappe.db.get_single_value("Buying Settings", "pr_required") == "Yes":
|
||||
stock_and_asset_items = self.get_stock_items()
|
||||
stock_and_asset_items.extend(self.get_asset_items())
|
||||
if frappe.get_value(
|
||||
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_receipt"
|
||||
):
|
||||
return
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_receipt and d.item_code in stock_items:
|
||||
if not d.purchase_receipt and d.item_code in stock_and_asset_items:
|
||||
msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code))
|
||||
msg += "<br><br>"
|
||||
msg += _(
|
||||
@@ -657,6 +659,27 @@ class PurchaseInvoice(BuyingController):
|
||||
if self.write_off_amount and not self.write_off_account:
|
||||
throw(_("Please enter Write Off Account"))
|
||||
|
||||
if not self.write_off_account:
|
||||
return
|
||||
|
||||
doc = frappe.db.get_value(
|
||||
"Account", self.write_off_account, ["report_type", "is_group", "company"], as_dict=True
|
||||
)
|
||||
|
||||
if not doc or doc.report_type != "Profit and Loss" or doc.is_group or doc.company != self.company:
|
||||
throw(_("Please enter a valid Write Off Account"))
|
||||
|
||||
def validate_write_off_cost_center(self):
|
||||
if not self.write_off_cost_center:
|
||||
return
|
||||
|
||||
doc = frappe.db.get_value(
|
||||
"Cost Center", self.write_off_cost_center, ["is_group", "company"], as_dict=True
|
||||
)
|
||||
|
||||
if not doc or doc.is_group or doc.company != self.company:
|
||||
throw(_("Please enter a valid Write Off Cost Center"))
|
||||
|
||||
def check_prev_docstatus(self):
|
||||
for d in self.get("items"):
|
||||
if d.purchase_order:
|
||||
@@ -737,6 +760,7 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
def validate_for_repost(self):
|
||||
self.validate_write_off_account()
|
||||
self.validate_write_off_cost_center()
|
||||
self.validate_expense_account()
|
||||
validate_docs_for_voucher_types(["Purchase Invoice"])
|
||||
validate_docs_for_deferred_accounting([], [self.name])
|
||||
@@ -850,7 +874,9 @@ class PurchaseInvoice(BuyingController):
|
||||
if update_outstanding == "No":
|
||||
update_voucher_outstanding(
|
||||
voucher_type=self.doctype,
|
||||
voucher_no=self.return_against if cint(self.is_return) and self.return_against else self.name,
|
||||
voucher_no=self.return_against
|
||||
if (cint(self.is_return) and self.return_against)
|
||||
else self.name,
|
||||
account=self.credit_to,
|
||||
party_type="Supplier",
|
||||
party=self.supplier,
|
||||
@@ -1533,6 +1559,9 @@ class PurchaseInvoice(BuyingController):
|
||||
def make_payment_gl_entries(self, gl_entries):
|
||||
# Make Cash GL Entries
|
||||
if cint(self.is_paid) and self.cash_bank_account and self.paid_amount:
|
||||
against_voucher = self.name
|
||||
if self.is_return and self.return_against and not self.update_outstanding_for_self:
|
||||
against_voucher = self.return_against
|
||||
bank_account_currency = get_account_currency(self.cash_bank_account)
|
||||
# CASH, make payment entries
|
||||
gl_entries.append(
|
||||
@@ -1547,9 +1576,7 @@ class PurchaseInvoice(BuyingController):
|
||||
if self.party_account_currency == self.company_currency
|
||||
else self.paid_amount,
|
||||
"debit_in_transaction_currency": self.paid_amount,
|
||||
"against_voucher": self.return_against
|
||||
if cint(self.is_return) and self.return_against
|
||||
else self.name,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": self.doctype,
|
||||
"cost_center": self.cost_center,
|
||||
"project": self.project,
|
||||
|
||||
@@ -180,12 +180,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
: "Inter Company Purchase Invoice";
|
||||
|
||||
me.frm.add_custom_button(
|
||||
button_label,
|
||||
__(button_label),
|
||||
function () {
|
||||
me.make_inter_company_invoice();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.get_received_items",
|
||||
args: {
|
||||
reference_name: me.frm.doc.name,
|
||||
doctype: "Purchase Invoice",
|
||||
reference_fieldname: "sales_invoice_item",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const received_items = r.message || {};
|
||||
const has_pending_qty = me.frm.doc.items.some(
|
||||
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
|
||||
);
|
||||
if (!has_pending_qty) {
|
||||
me.frm.remove_custom_button(__(button_label), __("Create"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,12 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
|
||||
get_party_tax_withholding_details,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
|
||||
from erpnext.accounts.party import (
|
||||
CROSS_PARTY_FIELD_NO_MAP,
|
||||
get_due_date,
|
||||
get_party_account,
|
||||
get_party_details,
|
||||
)
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
get_account_currency,
|
||||
@@ -2526,7 +2531,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
@@ -2559,18 +2564,25 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"set_target_warehouse": "set_from_warehouse",
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"],
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
|
||||
},
|
||||
doctype + " Item": item_field_map,
|
||||
},
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
)
|
||||
|
||||
if not doclist.get("items"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
|
||||
"Please check the existing linked {2}s."
|
||||
).format(target_doctype, doctype, target_doctype)
|
||||
)
|
||||
return doclist
|
||||
|
||||
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
@@ -2583,20 +2595,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
as_list=True,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
received_items_map = {}
|
||||
if target_doctypes:
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
frappe.get_all(
|
||||
received_items_data = frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
for item in received_items_data:
|
||||
key = item.get(reference_fieldname)
|
||||
if key:
|
||||
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
|
||||
|
||||
return received_items_map
|
||||
|
||||
|
||||
@@ -2690,6 +2690,95 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
|
||||
item_code_1 = "_Test IC Item 1"
|
||||
item_code_2 = "_Test IC Item 2"
|
||||
|
||||
create_item(item_code_1, is_stock_item=1)
|
||||
create_item(item_code_2, is_stock_item=1)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
item_code=item_code_1,
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
qty=3,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code_2,
|
||||
"item_name": item_code_2,
|
||||
"description": item_code_2,
|
||||
"warehouse": "Stores - WP",
|
||||
"qty": 2,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"rate": 100,
|
||||
"price_list_rate": 100,
|
||||
"income_account": "Sales - WP",
|
||||
"expense_account": "Cost of Goods Sold - WP",
|
||||
"cost_center": "Main - WP",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
for item in target_doc.items:
|
||||
item.update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
}
|
||||
)
|
||||
|
||||
target_doc.submit()
|
||||
self.assertEqual(len(target_doc.items), 2)
|
||||
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"already been fully invoiced",
|
||||
):
|
||||
make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
def test_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.tax_category = "_Test Tax Category 1"
|
||||
si.language = "ar"
|
||||
si.payment_terms_template = "_Test Payment Term Template"
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
|
||||
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
|
||||
self.assertEqual(pi.language or None, supplier.language or None)
|
||||
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
|
||||
|
||||
def test_inter_company_transaction_without_default_warehouse(self):
|
||||
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
|
||||
# setup
|
||||
|
||||
@@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = {
|
||||
}
|
||||
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
|
||||
|
||||
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
|
||||
# source and target documents belong to different parties (e.g. Sales Order →
|
||||
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
|
||||
CROSS_PARTY_FIELD_NO_MAP = [
|
||||
"tax_category",
|
||||
"tax_id",
|
||||
"tax_withholding_category",
|
||||
"taxes_and_charges",
|
||||
"address_display",
|
||||
"contact_display",
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"contact_person",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
"payment_terms_template",
|
||||
"language",
|
||||
]
|
||||
|
||||
|
||||
class DuplicatePartyAccountError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -94,19 +95,35 @@ def get_data(filters):
|
||||
def get_sales_details(filters):
|
||||
item_details_map = {}
|
||||
|
||||
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
|
||||
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
|
||||
|
||||
sales_data = frappe.db.sql(
|
||||
"""
|
||||
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
|
||||
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
|
||||
from `tab{doctype}` s, `tab{doctype} Item` si
|
||||
where s.name = si.parent and s.docstatus = 1
|
||||
order by days_since_last_order """.format( # nosec
|
||||
date_field=date_field, doctype=filters["based_on"]
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
parent = frappe.qb.DocType(filters["based_on"])
|
||||
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
|
||||
days_since_last_order = date_diff(current_date(), date_col)
|
||||
|
||||
sales_data = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
parent.territory,
|
||||
parent.customer,
|
||||
child.item_group,
|
||||
child.item_code,
|
||||
child.qty,
|
||||
date_col.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(parent.docstatus == 1)
|
||||
.orderby(days_since_last_order)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
|
||||
@@ -278,6 +278,7 @@ def get_balance_on(
|
||||
)
|
||||
|
||||
if party_type and party:
|
||||
frappe.has_permission(party_type, "read", party, throw=True)
|
||||
cond.append(
|
||||
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
|
||||
)
|
||||
@@ -397,15 +398,13 @@ def add_ac(args=None):
|
||||
if not args:
|
||||
args = frappe.local.form_dict
|
||||
|
||||
args.pop("ignore_permissions", None)
|
||||
frappe.has_permission("Account", "create", throw=True)
|
||||
|
||||
args.doctype = "Account"
|
||||
args = make_tree_args(**args)
|
||||
|
||||
ac = frappe.new_doc("Account")
|
||||
|
||||
if args.get("ignore_permissions"):
|
||||
ac.flags.ignore_permissions = True
|
||||
args.pop("ignore_permissions")
|
||||
|
||||
ac.update(args)
|
||||
|
||||
if not ac.parent_account:
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
@@ -272,12 +273,20 @@ class RequestforQuotation(BuyingController):
|
||||
supplier_doc.save()
|
||||
|
||||
def create_user(self, rfq_supplier, link):
|
||||
contact_name = None
|
||||
if rfq_supplier.contact:
|
||||
name_fields = frappe.get_value(
|
||||
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
|
||||
)
|
||||
if name_fields:
|
||||
contact_name = get_full_name(*name_fields)
|
||||
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"send_welcome_email": 0,
|
||||
"email": rfq_supplier.email_id,
|
||||
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"user_type": "Website User",
|
||||
"redirect_url": link,
|
||||
}
|
||||
|
||||
@@ -362,19 +362,22 @@
|
||||
"fieldname": "supplier_primary_contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Contact",
|
||||
"no_copy": 1,
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
"fetch_from": "supplier_primary_contact.mobile_no",
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Mobile No"
|
||||
"label": "Mobile No",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "supplier_primary_contact.email_id",
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Email Id"
|
||||
"label": "Email Id",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_44",
|
||||
@@ -384,6 +387,7 @@
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text",
|
||||
"label": "Primary Address",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -391,6 +395,7 @@
|
||||
"fieldname": "supplier_primary_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Address",
|
||||
"no_copy": 1,
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
@@ -486,7 +491,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-06 12:58:01.398824",
|
||||
"modified": "2026-05-29 16:52:59.441272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _, bold, qb, throw
|
||||
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
|
||||
from frappe.model.workflow import get_workflow_name
|
||||
from frappe.query_builder import Criterion, DocType
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
@@ -178,7 +178,7 @@ class AccountsController(TransactionBase):
|
||||
if not get_meta(self.doctype).has_field("outstanding_amount"):
|
||||
return
|
||||
|
||||
if self.get("is_return") and self.return_against and not self.get("is_pos"):
|
||||
if self.get("is_return") and self.return_against and not (self.get("is_pos") or self.get("is_paid")):
|
||||
against_voucher_outstanding = frappe.get_value(
|
||||
self.doctype, self.return_against, "outstanding_amount"
|
||||
)
|
||||
@@ -3815,7 +3815,9 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
|
||||
def update_child_qty_rate(
|
||||
parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items"
|
||||
):
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.selling.doctype.quotation.quotation import get_ordered_items
|
||||
|
||||
@@ -3841,14 +3843,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
current_state = doc.get(workflow_doc.workflow_state_field)
|
||||
roles = frappe.get_roles()
|
||||
|
||||
transitions = []
|
||||
for transition in workflow_doc.transitions:
|
||||
if transition.next_state == current_state and transition.allowed in roles:
|
||||
if not is_transition_condition_satisfied(transition, doc):
|
||||
continue
|
||||
transitions.append(transition.as_dict())
|
||||
allowed = any(
|
||||
state.state == current_state and (not state.allow_edit or state.allow_edit in roles)
|
||||
for state in workflow_doc.states
|
||||
)
|
||||
|
||||
if not transitions:
|
||||
if not allowed:
|
||||
frappe.throw(
|
||||
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
|
||||
get_link_to_form("Workflow", workflow)
|
||||
|
||||
@@ -370,10 +370,14 @@ def get_delivery_notes_to_be_billed(
|
||||
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
|
||||
)
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
"Delivery Note",
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(DeliveryNote)
|
||||
.select(*[DeliveryNote[f] for f in fields])
|
||||
.where(
|
||||
query.where(
|
||||
(DeliveryNote.docstatus == 1)
|
||||
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
|
||||
& (DeliveryNote[searchfield].like(f"%{txt}%"))
|
||||
@@ -387,12 +391,11 @@ def get_delivery_notes_to_be_billed(
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(DeliveryNote[searchfield], order=Order.asc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
if filters and isinstance(filters, dict):
|
||||
for key, value in filters.items():
|
||||
query = query.where(DeliveryNote[key] == value)
|
||||
|
||||
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
|
||||
return query.run(as_dict=as_dict)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class SellingController(StockController):
|
||||
self.validate_for_duplicate_items()
|
||||
self.validate_target_warehouse()
|
||||
self.validate_auto_repeat_subscription_dates()
|
||||
self.validate_sample_retention_warehouse()
|
||||
for table_field in ["items", "packed_items"]:
|
||||
if self.get(table_field):
|
||||
self.set_serial_and_batch_bundle(table_field)
|
||||
@@ -874,6 +875,26 @@ class SellingController(StockController):
|
||||
|
||||
validate_item_type(self, "is_sales_item", "sales")
|
||||
|
||||
def validate_sample_retention_warehouse(self):
|
||||
if self.get("is_return"):
|
||||
return
|
||||
|
||||
sample_retention_warehouse = frappe.db.get_single_value(
|
||||
"Stock Settings", "sample_retention_warehouse"
|
||||
)
|
||||
if not sample_retention_warehouse:
|
||||
return
|
||||
|
||||
items = self.get("items") + (self.get("packed_items"))
|
||||
for item in items:
|
||||
if item.get("warehouse") == sample_retention_warehouse:
|
||||
frappe.throw(
|
||||
_("Row {0}: Cannot sell item {1} from Sample Retention Warehouse {2}").format(
|
||||
item.idx, frappe.bold(item.item_code), frappe.bold(sample_retention_warehouse)
|
||||
),
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
|
||||
def update_stock_reservation_entries(self) -> None:
|
||||
"""Updates Delivered Qty in Stock Reservation Entries."""
|
||||
|
||||
|
||||
@@ -1680,7 +1680,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_item_quality_inspection(doctype, items):
|
||||
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
@@ -1692,13 +1692,30 @@ def check_item_quality_inspection(doctype, items):
|
||||
"Delivery Note": "inspection_required_before_delivery",
|
||||
}
|
||||
|
||||
items_to_remove = []
|
||||
for item in items:
|
||||
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
|
||||
items_to_remove.append(item)
|
||||
items = [item for item in items if item not in items_to_remove]
|
||||
inspection_fieldname = inspection_fieldname_map.get(doctype)
|
||||
if inspection_fieldname is None:
|
||||
return []
|
||||
|
||||
return items
|
||||
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
)
|
||||
|
||||
if allow_after_transaction:
|
||||
return items
|
||||
|
||||
item_codes = list({item.get("item_code") for item in items})
|
||||
|
||||
Item = frappe.qb.DocType("Item")
|
||||
results = (
|
||||
frappe.qb.from_(Item)
|
||||
.select(Item.name)
|
||||
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
inspection_required_items = {row.name for row in results}
|
||||
|
||||
return [item for item in items if item.get("item_code") in inspection_required_items]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -178,13 +178,16 @@ def get_list_for_transactions(
|
||||
|
||||
|
||||
def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_page_length):
|
||||
data = frappe.db.sql(
|
||||
"""select distinct parent as name, supplier from `tab{doctype}`
|
||||
where supplier = '{supplier}' and docstatus=1 order by modified desc limit {start}, {len}""".format(
|
||||
doctype=parties_doctype, supplier=parties[0], start=limit_start, len=limit_page_length
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
party = frappe.qb.DocType(parties_doctype)
|
||||
data = (
|
||||
frappe.qb.from_(party)
|
||||
.select(party.parent.as_("name"), party.supplier)
|
||||
.distinct()
|
||||
.where((party.supplier == party[0]) & (party.docstatus == 1))
|
||||
.orderby(party.creation, order=frappe.qb.desc)
|
||||
.limit(limit_page_length)
|
||||
.offset(limit_start)
|
||||
).run(as_dict=True)
|
||||
|
||||
return post_process(doctype, data)
|
||||
|
||||
|
||||
@@ -32,20 +32,16 @@ def create_custom_fields_for_frappe_crm():
|
||||
@frappe.whitelist()
|
||||
def create_prospect_against_crm_deal():
|
||||
doc = frappe.form_dict
|
||||
prospect = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Prospect",
|
||||
"company_name": doc.organization or doc.lead_name,
|
||||
"no_of_employees": doc.no_of_employees,
|
||||
"prospect_owner": doc.deal_owner,
|
||||
"company": doc.erpnext_company,
|
||||
"crm_deal": doc.crm_deal,
|
||||
"territory": doc.territory,
|
||||
"industry": doc.industry,
|
||||
"website": doc.website,
|
||||
"annual_revenue": doc.annual_revenue,
|
||||
}
|
||||
)
|
||||
prospect = frappe.new_doc("Prospect")
|
||||
prospect.company_name = doc.organization or doc.lead_name
|
||||
prospect.no_of_employees = doc.no_of_employees
|
||||
prospect.prospect_owner = doc.deal_owner
|
||||
prospect.company = doc.erpnext_company
|
||||
prospect.crm_deal = doc.crm_deal
|
||||
prospect.territory = doc.territory
|
||||
prospect.industry = doc.industry
|
||||
prospect.website = doc.website
|
||||
prospect.annual_revenue = doc.annual_revenue
|
||||
|
||||
try:
|
||||
prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name})
|
||||
@@ -151,6 +147,18 @@ def contact_exists(email, mobile_no):
|
||||
return False
|
||||
|
||||
|
||||
CUSTOMER_ALLOWED_FIELDS = {
|
||||
"customer_name",
|
||||
"customer_group",
|
||||
"customer_type",
|
||||
"territory",
|
||||
"default_currency",
|
||||
"industry",
|
||||
"website",
|
||||
"crm_deal",
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_customer(customer_data=None):
|
||||
if not customer_data:
|
||||
@@ -159,9 +167,11 @@ def create_customer(customer_data=None):
|
||||
try:
|
||||
customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")})
|
||||
if not customer_name:
|
||||
customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
customer = frappe.new_doc("Customer")
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
|
||||
@@ -438,7 +438,7 @@ frappe.ui.form.on("BOM", {
|
||||
},
|
||||
|
||||
routing(frm) {
|
||||
if (frm.doc.routing) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_routing",
|
||||
|
||||
@@ -126,11 +126,13 @@
|
||||
"label": "Image"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fetch_from": "operation.batch_size",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "batch_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Batch Size"
|
||||
"fieldtype": "Float",
|
||||
"label": "Batch Size",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
|
||||
@@ -196,13 +198,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-17 15:33:28.495850",
|
||||
"modified": "2026-05-27 12:09:44.797434",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class BOMOperation(Document):
|
||||
base_cost_per_unit: DF.Float
|
||||
base_hour_rate: DF.Currency
|
||||
base_operating_cost: DF.Currency
|
||||
batch_size: DF.Int
|
||||
batch_size: DF.Float
|
||||
cost_per_unit: DF.Float
|
||||
description: DF.TextEditor | None
|
||||
fixed_time: DF.Check
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"min_order_qty",
|
||||
"section_break_8",
|
||||
"sales_order",
|
||||
"main_item_code",
|
||||
"bin_qty_section",
|
||||
"actual_qty",
|
||||
"requested_qty",
|
||||
@@ -114,6 +115,14 @@
|
||||
"options": "Sales Order",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "main_item_code",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Main Item Code",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "requested_qty",
|
||||
"fieldtype": "Float",
|
||||
@@ -213,4 +222,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class MaterialRequestPlanItem(Document):
|
||||
from_warehouse: DF.Link | None
|
||||
item_code: DF.Link
|
||||
item_name: DF.Data | None
|
||||
main_item_code: DF.Data | None
|
||||
material_request_type: DF.Literal[
|
||||
"", "Purchase", "Material Transfer", "Material Issue", "Manufacture", "Customer Provided"
|
||||
]
|
||||
|
||||
@@ -1397,7 +1397,7 @@ def get_material_request_items(
|
||||
"sales_order": sales_order,
|
||||
"description": row.get("description"),
|
||||
"uom": row.get("purchase_uom") or row.get("stock_uom"),
|
||||
"main_bom_item": row.get("main_bom_item"),
|
||||
"main_item_code": row.get("main_bom_item"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1557,6 +1557,8 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
"item_code": sa_row.production_item,
|
||||
"required_qty": sa_row.qty,
|
||||
"include_exploded_items": 0,
|
||||
"sales_order": sa_row.sales_order,
|
||||
"main_bom_item": sa_row.parent_item_code,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1660,6 +1662,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
"stock_uom": item_master.stock_uom,
|
||||
"conversion_factor": conversion_factor,
|
||||
"safety_stock": item_master.safety_stock,
|
||||
"main_bom_item": data.get("main_bom_item"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1254,8 +1254,10 @@ class TestProductionPlan(FrappeTestCase):
|
||||
plan.get_sub_assembly_items()
|
||||
|
||||
mr_items = []
|
||||
expected_main_item_by_mr_item = {"ChildPart1 For MR": "SubAssembly1-1 For MR"}
|
||||
for row in plan.sub_assembly_items:
|
||||
mr_items.append(row.production_item)
|
||||
expected_main_item_by_mr_item[row.production_item] = row.parent_item_code
|
||||
row.type_of_manufacturing = "Material Request"
|
||||
|
||||
plan.save()
|
||||
@@ -1265,6 +1267,10 @@ class TestProductionPlan(FrappeTestCase):
|
||||
for item_code in mr_items:
|
||||
self.assertTrue(item_code in validate_mr_items)
|
||||
|
||||
main_item_by_mr_item = {item.get("item_code"): item.get("main_item_code") for item in items}
|
||||
for item_code, main_item_code in expected_main_item_by_mr_item.items():
|
||||
self.assertEqual(main_item_by_mr_item[item_code], main_item_code)
|
||||
|
||||
def test_resered_qty_for_production_plan_for_material_requests(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ class WorkOrder(Document):
|
||||
self.calculate_operating_cost()
|
||||
self.validate_qty()
|
||||
self.validate_transfer_against()
|
||||
self.validate_operation_time()
|
||||
self.validate_operations()
|
||||
self.status = self.get_status()
|
||||
self.validate_workstation_type()
|
||||
self.reset_use_multi_level_bom()
|
||||
@@ -406,7 +406,7 @@ class WorkOrder(Document):
|
||||
elif self.docstatus == 1:
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if flt(self.material_transferred_for_manufacturing) > 0:
|
||||
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
|
||||
status = "In Process"
|
||||
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
@@ -1120,9 +1120,12 @@ class WorkOrder(Document):
|
||||
title=_("Missing value"),
|
||||
)
|
||||
|
||||
def validate_operation_time(self):
|
||||
def validate_operations(self):
|
||||
for d in self.operations:
|
||||
if not d.time_in_mins > 0:
|
||||
if not d.batch_size or d.batch_size <= 0:
|
||||
d.batch_size = 1
|
||||
|
||||
if d.time_in_mins <= 0:
|
||||
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))
|
||||
|
||||
def update_required_items(self):
|
||||
|
||||
@@ -185,10 +185,11 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "batch_size",
|
||||
"fieldtype": "Float",
|
||||
"label": "Batch Size",
|
||||
"read_only": 1
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sequence_id",
|
||||
@@ -225,14 +226,15 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-15 15:10:06.885440",
|
||||
"modified": "2026-05-27 12:56:37.240431",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Operation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -435,3 +435,4 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
erpnext.patches.v16_0.depends_on_inv_dimensions
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
frappe.reload_doc("manufacturing", "doctype", "material_request_plan_item")
|
||||
|
||||
if not frappe.db.has_column("Material Request Plan Item", "main_item_code"):
|
||||
return
|
||||
|
||||
for row in get_material_request_plan_items():
|
||||
if row.main_item_code:
|
||||
continue
|
||||
|
||||
main_item_code = get_main_item_code(row)
|
||||
if main_item_code:
|
||||
frappe.db.set_value(
|
||||
"Material Request Plan Item",
|
||||
row.name,
|
||||
"main_item_code",
|
||||
main_item_code,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
|
||||
def get_material_request_plan_items():
|
||||
return frappe.get_all(
|
||||
"Material Request Plan Item",
|
||||
fields=["name", "parent", "item_code", "sales_order", "main_item_code"],
|
||||
)
|
||||
|
||||
|
||||
def get_main_item_code(row):
|
||||
return (
|
||||
get_main_item_code_from_sub_assembly(row)
|
||||
or get_main_item_code_from_sub_assembly_bom(row)
|
||||
or get_main_item_code_from_production_plan_bom(row)
|
||||
)
|
||||
|
||||
|
||||
def get_main_item_code_from_sub_assembly(row):
|
||||
sub_assembly = frappe.db.get_value(
|
||||
"Production Plan Sub Assembly Item",
|
||||
get_filters(
|
||||
row,
|
||||
{
|
||||
"parent": row.parent,
|
||||
"production_item": row.item_code,
|
||||
},
|
||||
),
|
||||
"parent_item_code",
|
||||
)
|
||||
|
||||
return sub_assembly
|
||||
|
||||
|
||||
def get_main_item_code_from_sub_assembly_bom(row):
|
||||
for sub_assembly in get_sub_assembly_items(row):
|
||||
if item_exists_in_bom(row.item_code, sub_assembly.bom_no):
|
||||
return frappe.db.get_value("BOM", sub_assembly.bom_no, "item")
|
||||
|
||||
|
||||
def get_main_item_code_from_production_plan_bom(row):
|
||||
for production_plan_item in get_production_plan_items(row):
|
||||
if item_exists_in_bom(row.item_code, production_plan_item.bom_no):
|
||||
return frappe.db.get_value("BOM", production_plan_item.bom_no, "item")
|
||||
|
||||
|
||||
def get_sub_assembly_items(row):
|
||||
return frappe.get_all(
|
||||
"Production Plan Sub Assembly Item",
|
||||
filters=get_filters(row, {"parent": row.parent}),
|
||||
fields=["bom_no"],
|
||||
)
|
||||
|
||||
|
||||
def get_production_plan_items(row):
|
||||
return frappe.get_all(
|
||||
"Production Plan Item",
|
||||
filters=get_filters(row, {"parent": row.parent}),
|
||||
fields=["bom_no"],
|
||||
)
|
||||
|
||||
|
||||
def get_filters(row, filters):
|
||||
if row.sales_order:
|
||||
filters["sales_order"] = row.sales_order
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def item_exists_in_bom(item_code, bom_no):
|
||||
if not bom_no:
|
||||
return False
|
||||
|
||||
return frappe.db.exists("BOM Item", {"parent": bom_no, "item_code": item_code}) or frappe.db.exists(
|
||||
"BOM Explosion Item", {"parent": bom_no, "item_code": item_code}
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"beta": 1,
|
||||
"creation": "2016-04-22 05:27:52.109319",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
@@ -87,7 +86,7 @@
|
||||
],
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-12-19 21:10:29.127277",
|
||||
"modified": "2026-05-30 20:51:04.415019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Portal",
|
||||
"name": "Homepage",
|
||||
@@ -114,6 +113,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -176,14 +176,9 @@ erpnext.buying = {
|
||||
callback: (r) => {
|
||||
if (!r.message) return;
|
||||
|
||||
if (!this.frm.doc.billing_address) {
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
}
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
|
||||
if (
|
||||
frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") &&
|
||||
!this.frm.doc.shipping_address
|
||||
) {
|
||||
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
|
||||
this.frm.set_value("shipping_address", r.message.shipping_address || "");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -418,7 +418,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
if(this.frm.doc.__islocal && !(this.frm.doc.taxes || []).length
|
||||
&& !this.frm.doc.__onload?.load_after_mapping) {
|
||||
@@ -659,15 +658,23 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
},
|
||||
async () => {
|
||||
// for internal customer instead of pricing rule directly apply valuation rate on item
|
||||
const fetch_valuation_rate_for_internal_transactions = await frappe.db.get_single_value(
|
||||
"Accounts Settings", "fetch_valuation_rate_for_internal_transaction"
|
||||
);
|
||||
if ((me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) && fetch_valuation_rate_for_internal_transactions) {
|
||||
me.get_incoming_rate(item, me.frm.posting_date, me.frm.posting_time,
|
||||
me.frm.doc.doctype, me.frm.doc.company);
|
||||
} else {
|
||||
me.frm.script_manager.trigger("price_list_rate", cdt, cdn);
|
||||
if ((me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier)) {
|
||||
const fetch_valuation_rate_for_internal_transactions = await frappe.db.get_single_value(
|
||||
"Accounts Settings", "fetch_valuation_rate_for_internal_transaction"
|
||||
);
|
||||
if (fetch_valuation_rate_for_internal_transactions) {
|
||||
me.get_incoming_rate(
|
||||
item,
|
||||
me.frm.posting_date,
|
||||
me.frm.posting_time,
|
||||
me.frm.doc.doctype,
|
||||
me.frm.doc.company
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
me.frm.script_manager.trigger("price_list_rate", cdt, cdn);
|
||||
},
|
||||
() => {
|
||||
if (me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) {
|
||||
@@ -2506,11 +2513,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
method: "erpnext.controllers.stock_controller.check_item_quality_inspection",
|
||||
args: {
|
||||
doctype: this.frm.doc.doctype,
|
||||
items: this.frm.doc.items
|
||||
docstatus: this.frm.doc.docstatus,
|
||||
items: this.frm.doc.items,
|
||||
},
|
||||
freeze: true,
|
||||
callback: function (r) {
|
||||
r.message.forEach(item => {
|
||||
if (r.message.length == 0) {
|
||||
let type = inspection_type === "Incoming" ? "Purchase" : "Delivery";
|
||||
let fieldname =
|
||||
inspection_type === "Incoming"
|
||||
? "Inspection Required before Purchase"
|
||||
: "Inspection Required before Delivery";
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Quality Inspection Not Configured"),
|
||||
message: __(`Enable <b>{0}</b> on the Item master to proceed with {1} inspection.`, [
|
||||
fieldname,
|
||||
type,
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
r.message.forEach((item) => {
|
||||
if (me.has_inspection_required(item)) {
|
||||
let dialog_items = dialog.fields_dict.items;
|
||||
dialog_items.df.data.push({
|
||||
|
||||
@@ -651,6 +651,7 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
read_only: 0,
|
||||
disabled: 0,
|
||||
label: __("Item Code"),
|
||||
formatter: (value) => value,
|
||||
get_query: function () {
|
||||
let filters;
|
||||
if (frm.doc.doctype == "Sales Order") {
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
"fieldname": "customer_primary_contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer Primary Contact",
|
||||
"no_copy": 1,
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
@@ -312,6 +313,7 @@
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Mobile No",
|
||||
"no_copy": 1,
|
||||
"options": "Mobile"
|
||||
},
|
||||
{
|
||||
@@ -319,6 +321,7 @@
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Email Id",
|
||||
"no_copy": 1,
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
@@ -330,12 +333,14 @@
|
||||
"fieldname": "customer_primary_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer Primary Address",
|
||||
"no_copy": 1,
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text",
|
||||
"label": "Primary Address",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -590,14 +595,16 @@
|
||||
"fieldname": "first_name",
|
||||
"fieldtype": "Read Only",
|
||||
"hidden": 1,
|
||||
"label": "First Name"
|
||||
"label": "First Name",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "customer_primary_contact.last_name",
|
||||
"fieldname": "last_name",
|
||||
"fieldtype": "Read Only",
|
||||
"hidden": 1,
|
||||
"label": "Last Name"
|
||||
"label": "Last Name",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-user",
|
||||
@@ -611,7 +618,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-21 17:23:42.151114",
|
||||
"modified": "2026-05-29 16:52:59.441272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Customer",
|
||||
|
||||
@@ -308,7 +308,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:(doc.quotation_to=='Customer' && doc.party_name)",
|
||||
"fieldname": "col_break98",
|
||||
"fieldtype": "Column Break",
|
||||
"width": "50%"
|
||||
@@ -1108,7 +1107,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-31 17:23:48.875382",
|
||||
"modified": "2026-05-30 17:40:02.667637",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -26,7 +26,7 @@ frappe.ui.form.on("Sales Order", {
|
||||
let color;
|
||||
if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
color = "yellow";
|
||||
} else if (doc.stock_qty <= doc.actual_qty) {
|
||||
} else if (doc.stock_qty - doc.delivered_qty <= doc.actual_qty) {
|
||||
color = "green";
|
||||
} else {
|
||||
color = "orange";
|
||||
|
||||
@@ -10,6 +10,7 @@ import frappe.utils
|
||||
from frappe import _, qb
|
||||
from frappe.contacts.doctype.address.address import get_company_address
|
||||
from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -20,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
update_linked_doc,
|
||||
validate_inter_company_party,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
@@ -1332,7 +1333,9 @@ def get_events(start, end, filters=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None):
|
||||
def make_purchase_order_for_default_supplier(
|
||||
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
|
||||
):
|
||||
"""Creates Purchase Order for each Supplier. Returns a list of doc objects."""
|
||||
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
@@ -1361,7 +1364,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
target.shipping_rule = ""
|
||||
target.tc_name = ""
|
||||
target.terms = ""
|
||||
target.payment_terms_template = ""
|
||||
target.payment_schedule = []
|
||||
|
||||
default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
|
||||
@@ -1418,16 +1420,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
"Sales Order Item": {
|
||||
@@ -1492,7 +1485,9 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
def make_purchase_order(
|
||||
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
|
||||
):
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
@@ -1520,7 +1515,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
target.shipping_rule = ""
|
||||
target.tc_name = ""
|
||||
target.terms = ""
|
||||
target.payment_terms_template = ""
|
||||
target.payment_schedule = []
|
||||
|
||||
if is_drop_ship_order(target):
|
||||
@@ -1559,16 +1553,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
"Sales Order Item": {
|
||||
|
||||
@@ -24,6 +24,8 @@ from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
create_pick_list,
|
||||
make_delivery_note,
|
||||
make_material_request,
|
||||
make_purchase_order,
|
||||
make_purchase_order_for_default_supplier,
|
||||
make_raw_material_request,
|
||||
make_sales_invoice,
|
||||
make_work_orders,
|
||||
@@ -692,6 +694,40 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
workflow.is_active = 0
|
||||
workflow.save()
|
||||
|
||||
def test_update_child_qty_rate_follows_allow_edit(self):
|
||||
from frappe.model.workflow import apply_workflow
|
||||
|
||||
workflow = make_sales_order_edit_perm_workflow()
|
||||
so = make_sales_order(item_code="_Test Item", qty=1, rate=150, do_not_submit=1)
|
||||
apply_workflow(so, "Approve")
|
||||
|
||||
trans_item = json.dumps(
|
||||
[{"item_code": "_Test Item", "rate": 150, "qty": 2, "docname": so.items[0].name}]
|
||||
)
|
||||
|
||||
mover = "test@example.com"
|
||||
mover_user = frappe.get_doc("User", mover)
|
||||
mover_user.add_roles("Sales User", "Test Junior Approver")
|
||||
with self.set_user(mover):
|
||||
# transitioned the doc into Approved but is not the configured editor
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name
|
||||
)
|
||||
|
||||
editor = "test2@example.com"
|
||||
editor_user = frappe.get_doc("User", editor)
|
||||
editor_user.add_roles("Sales User", "Test Approver")
|
||||
with self.set_user(editor):
|
||||
# Test Approver is the "Only Allow Edit For" role on Approved
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].qty, 2)
|
||||
|
||||
mover_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
|
||||
editor_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
|
||||
workflow.is_active = 0
|
||||
workflow.save()
|
||||
|
||||
def test_material_request_for_product_bundle(self):
|
||||
# Create the Material Request from the sales order for the Packing Items
|
||||
# Check whether the material request has the correct packing item or not.
|
||||
@@ -1330,8 +1366,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
Tests if the the Product Bundles in the Items table of Sales Orders are replaced with
|
||||
their child items(from the Packed Items table) on creating a Purchase Order from it.
|
||||
"""
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
|
||||
|
||||
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
|
||||
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
|
||||
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
|
||||
@@ -1360,8 +1394,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
"""
|
||||
Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
|
||||
"""
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
|
||||
|
||||
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
|
||||
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
|
||||
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
|
||||
@@ -2385,8 +2417,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertRaises(frappe.ValidationError, so1.update_status, "Draft")
|
||||
|
||||
def test_item_tax_transfer_from_sales_to_purchase(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
|
||||
|
||||
item_tax = frappe.new_doc("Item Tax Template")
|
||||
item_tax.title = "Test Item Tax Template"
|
||||
item_tax.company = "_Test Company"
|
||||
@@ -2487,6 +2517,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertFalse(so.per_billed)
|
||||
self.assertEqual(so.status, "To Deliver and Bill")
|
||||
|
||||
def test_make_purchase_order_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Customer-derived fields must not leak from a drop-ship SO into the PO.
|
||||
"""
|
||||
so_items = [
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"delivered_by_supplier": 1,
|
||||
"supplier": "_Test Supplier",
|
||||
}
|
||||
]
|
||||
so = make_sales_order(item_list=so_items, do_not_submit=True)
|
||||
so.tax_category = "_Test Tax Category 1"
|
||||
so.language = "ar"
|
||||
so.payment_terms_template = "_Test Payment Term Template"
|
||||
so.submit()
|
||||
|
||||
po = make_purchase_order_for_default_supplier(so.name, selected_items=so_items)[0]
|
||||
|
||||
supplier = frappe.get_doc("Supplier", "_Test Supplier")
|
||||
self.assertEqual(po.tax_category or None, supplier.tax_category or None)
|
||||
self.assertEqual(po.language or None, supplier.language or None)
|
||||
self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None)
|
||||
|
||||
def test_pending_quantity_after_update_item_during_invoice_creation(self):
|
||||
so = make_sales_order(qty=30, rate=100)
|
||||
|
||||
@@ -2683,3 +2740,41 @@ def make_sales_order_workflow():
|
||||
workflow.insert(ignore_permissions=True)
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
def make_sales_order_edit_perm_workflow():
|
||||
if frappe.db.exists("Workflow", "SO Edit Perm Workflow"):
|
||||
doc = frappe.get_doc("Workflow", "SO Edit Perm Workflow")
|
||||
doc.set("is_active", 1)
|
||||
doc.save()
|
||||
return doc
|
||||
|
||||
frappe.get_doc(doctype="Role", role_name="Test Junior Approver").insert(ignore_if_duplicate=True)
|
||||
frappe.get_doc(doctype="Role", role_name="Test Approver").insert(ignore_if_duplicate=True)
|
||||
frappe.cache().hdel("roles", frappe.session.user)
|
||||
|
||||
workflow = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Workflow",
|
||||
"workflow_name": "SO Edit Perm Workflow",
|
||||
"document_type": "Sales Order",
|
||||
"workflow_state_field": "workflow_state",
|
||||
"is_active": 1,
|
||||
"send_email_alert": 0,
|
||||
}
|
||||
)
|
||||
workflow.append("states", dict(state="Pending", allow_edit="All"))
|
||||
workflow.append("states", dict(state="Approved", allow_edit="Test Approver", doc_status=1))
|
||||
workflow.append(
|
||||
"transitions",
|
||||
dict(
|
||||
state="Pending",
|
||||
action="Approve",
|
||||
next_state="Approved",
|
||||
allowed="Test Junior Approver",
|
||||
allow_self_approval=1,
|
||||
),
|
||||
)
|
||||
workflow.insert(ignore_permissions=True)
|
||||
|
||||
return workflow
|
||||
|
||||
@@ -377,42 +377,80 @@ def get_past_order_list(search_term, status, limit=20):
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_customer_info(fieldname, customer, value=""):
|
||||
customer_doc = frappe.get_doc("Customer", customer)
|
||||
customer_doc.check_permission("write")
|
||||
|
||||
if fieldname == "loyalty_program":
|
||||
frappe.db.set_value("Customer", customer, "loyalty_program", value)
|
||||
customer_doc.loyalty_program = value
|
||||
else:
|
||||
contact = customer_doc.get("customer_primary_contact")
|
||||
if not contact:
|
||||
Contact = DocType("Contact")
|
||||
DynamicLink = DocType("Dynamic Link")
|
||||
|
||||
contact = frappe.get_cached_value("Customer", customer, "customer_primary_contact")
|
||||
if not contact:
|
||||
contact = frappe.db.sql(
|
||||
"""
|
||||
SELECT parent FROM `tabDynamic Link`
|
||||
WHERE
|
||||
parenttype = 'Contact' AND
|
||||
parentfield = 'links' AND
|
||||
link_doctype = 'Customer' AND
|
||||
link_name = %s
|
||||
""",
|
||||
(customer),
|
||||
as_dict=1,
|
||||
)
|
||||
contact = contact[0].get("parent") if contact else None
|
||||
# Inner join with Contact DocType, to priorities records that have is_primary_contact set.
|
||||
query = (
|
||||
frappe.qb.from_(DynamicLink)
|
||||
.join(Contact)
|
||||
.on(DynamicLink.parent == Contact.name)
|
||||
.select(DynamicLink.parent)
|
||||
.where(
|
||||
(DynamicLink.link_name == customer)
|
||||
& (DynamicLink.parentfield == "links")
|
||||
& (DynamicLink.parenttype == "Contact")
|
||||
& (DynamicLink.link_doctype == "Customer")
|
||||
)
|
||||
.orderby(Contact.is_primary_contact, order=Order.desc)
|
||||
)
|
||||
|
||||
if not contact:
|
||||
new_contact = frappe.new_doc("Contact")
|
||||
new_contact.is_primary_contact = 1
|
||||
new_contact.first_name = customer
|
||||
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
|
||||
new_contact.save()
|
||||
contact = new_contact.name
|
||||
frappe.db.set_value("Customer", customer, "customer_primary_contact", contact)
|
||||
contacts = query.run(pluck=DynamicLink.parent)
|
||||
|
||||
contact_doc = frappe.get_doc("Contact", contact)
|
||||
if fieldname == "email_id":
|
||||
contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}])
|
||||
frappe.db.set_value("Customer", customer, "email_id", value)
|
||||
elif fieldname == "mobile_no":
|
||||
contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}])
|
||||
frappe.db.set_value("Customer", customer, "mobile_no", value)
|
||||
contact_doc.save()
|
||||
contact = contacts[0] if contacts else None
|
||||
|
||||
if not contact:
|
||||
new_contact = frappe.new_doc("Contact")
|
||||
new_contact.is_primary_contact = 1
|
||||
new_contact.first_name = customer
|
||||
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
|
||||
new_contact.save()
|
||||
contact = new_contact.name
|
||||
|
||||
def set_primary_phone_no_email(field, value):
|
||||
# Create new record instead deleting existing email or phone_no and setting the new row as primary.
|
||||
field_mapper = {
|
||||
"email_ids": {"field": "email_id", "primary": "is_primary"},
|
||||
"phone_nos": {"field": "phone", "primary": "is_primary_mobile_no"},
|
||||
}
|
||||
|
||||
value_already_exists = False
|
||||
for d in contact_doc.get(field):
|
||||
if d.get(field_mapper[field].get("field")) == value and not value_already_exists:
|
||||
d.set(field_mapper[field]["primary"], 1)
|
||||
value_already_exists = True
|
||||
continue
|
||||
d.set(field_mapper[field]["primary"], 0)
|
||||
|
||||
if not value_already_exists:
|
||||
contact_doc.append(
|
||||
field, {field_mapper[field]["field"]: value, field_mapper[field]["primary"]: 1}
|
||||
)
|
||||
|
||||
contact_doc = frappe.get_doc("Contact", contact)
|
||||
# setting is_primary_contact = 1 on Contact to refetch the same contact incase it's removed from Customer records.
|
||||
contact_doc.set("is_primary_contact", 1)
|
||||
if fieldname == "email_id":
|
||||
set_primary_phone_no_email("email_ids", value)
|
||||
elif fieldname == "mobile_no":
|
||||
set_primary_phone_no_email("phone_nos", value)
|
||||
# Saving contact_doc to set mobile_no and email.
|
||||
contact_doc.save()
|
||||
|
||||
# Auto-fetches from Contact DocType, no need to set values separately.
|
||||
customer_doc.customer_primary_contact = contact
|
||||
|
||||
# using save method instead db.set_value which bypasses the validation for loyalty program
|
||||
# and auto sets the mobile_no and email field on customer records.
|
||||
customer_doc.save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -174,8 +174,8 @@ erpnext.PointOfSale.Controller = class {
|
||||
set_opening_entry_status() {
|
||||
this.page.set_title_sub(
|
||||
`<span class="indicator orange">
|
||||
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${this.pos_opening}">
|
||||
Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)}
|
||||
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${encodeURIComponent(this.pos_opening)}">
|
||||
Opened at ${frappe.utils.escape_html(frappe.datetime.str_to_user(this.pos_opening_time))}
|
||||
</a>
|
||||
</span>`
|
||||
);
|
||||
|
||||
@@ -178,7 +178,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
me.$totals_section.find(".edit-cart-btn").click();
|
||||
}
|
||||
|
||||
const item_row_name = unescape($cart_item.attr("data-row-name"));
|
||||
const item_row_name = $cart_item.attr("data-row-name");
|
||||
me.events.cart_item_clicked({ name: item_row_name });
|
||||
this.numpad_value = "";
|
||||
});
|
||||
@@ -453,10 +453,10 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="customer-display">
|
||||
${this.get_customer_image()}
|
||||
<div class="customer-name-desc">
|
||||
<div class="customer-name">${customer_name}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
|
||||
${get_customer_description()}
|
||||
</div>
|
||||
<div class="reset-customer-btn" data-customer="${escape(customer)}">
|
||||
<div class="reset-customer-btn" data-customer="${frappe.utils.escape_html(customer)}">
|
||||
<svg width="32" height="32" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/>
|
||||
</svg>
|
||||
@@ -473,11 +473,13 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (!email_id && !mobile_no) {
|
||||
return `<div class="customer-desc">${__("Click to add email / phone")}</div>`;
|
||||
} else if (email_id && !mobile_no) {
|
||||
return `<div class="customer-desc">${email_id}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(email_id)}</div>`;
|
||||
} else if (mobile_no && !email_id) {
|
||||
return `<div class="customer-desc">${mobile_no}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(mobile_no)}</div>`;
|
||||
} else {
|
||||
return `<div class="customer-desc">${email_id} - ${mobile_no}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(
|
||||
email_id
|
||||
)} - ${frappe.utils.escape_html(mobile_no)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,9 +487,13 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
get_customer_image() {
|
||||
const { customer, image } = this.customer_info || {};
|
||||
if (image) {
|
||||
return `<div class="customer-image"><img src="${image}" alt="${image}""></div>`;
|
||||
return `<div class="customer-image"><img src="${frappe.utils.escape_html(
|
||||
image
|
||||
)}" alt="${frappe.utils.escape_html(image)}"></div>`;
|
||||
} else {
|
||||
return `<div class="customer-image customer-abbr">${frappe.get_abbr(customer)}</div>`;
|
||||
return `<div class="customer-image customer-abbr">${frappe.utils.escape_html(
|
||||
frappe.get_abbr(customer)
|
||||
)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,10 +555,10 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (t.tax_amount_after_discount_amount == 0.0) return;
|
||||
// if tax rate is 0, don't print it.
|
||||
const description = /[0-9]+/.test(t.description)
|
||||
? t.description
|
||||
? frappe.utils.escape_html(t.description)
|
||||
: t.rate != 0
|
||||
? `${t.description} @ ${t.rate}%`
|
||||
: t.description;
|
||||
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
|
||||
: frappe.utils.escape_html(t.description);
|
||||
return `<div class="tax-row">
|
||||
<div class="tax-label">${description}</div>
|
||||
<div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div>
|
||||
@@ -566,8 +572,9 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
}
|
||||
|
||||
get_cart_item({ name }) {
|
||||
const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`;
|
||||
return this.$cart_items_wrapper.find(item_selector);
|
||||
return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () {
|
||||
return $(this).attr("data-row-name") === name;
|
||||
});
|
||||
}
|
||||
|
||||
get_item_from_frm(item) {
|
||||
@@ -597,7 +604,9 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
|
||||
if (!$item_to_update.length) {
|
||||
this.$cart_items_wrapper.append(
|
||||
`<div class="cart-item-wrapper" data-row-name="${escape(item_data.name)}"></div>
|
||||
`<div class="cart-item-wrapper" data-row-name="${frappe.utils.escape_html(
|
||||
item_data.name
|
||||
)}"></div>
|
||||
<div class="seperator"></div>`
|
||||
);
|
||||
$item_to_update = this.get_cart_item(item_data);
|
||||
@@ -607,7 +616,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
`${get_item_image_html()}
|
||||
<div class="item-name-desc">
|
||||
<div class="item-name">
|
||||
${item_data.item_name}
|
||||
${frappe.utils.escape_html(item_data.item_name)}
|
||||
</div>
|
||||
${get_description_html()}
|
||||
</div>
|
||||
@@ -636,7 +645,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
|
||||
return `
|
||||
<div class="item-qty-rate">
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
|
||||
<div class="item-rate-amount">
|
||||
<div class="item-rate">${format_currency(item_data.amount, currency)}</div>
|
||||
<div class="item-amount">${format_currency(item_data.rate, currency)}</div>
|
||||
@@ -645,7 +654,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
} else {
|
||||
return `
|
||||
<div class="item-qty-rate">
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
|
||||
<div class="item-rate-amount">
|
||||
<div class="item-rate">${format_currency(item_data.rate, currency)}</div>
|
||||
</div>
|
||||
@@ -666,7 +675,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
}
|
||||
}
|
||||
item_data.description = frappe.ellipsis(item_data.description, 45);
|
||||
return `<div class="item-desc">${item_data.description}</div>`;
|
||||
return `<div class="item-desc">${frappe.utils.escape_html(item_data.description)}</div>`;
|
||||
}
|
||||
return ``;
|
||||
}
|
||||
@@ -678,22 +687,26 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="item-image">
|
||||
<img
|
||||
onerror="cur_pos.cart.handle_broken_image(this)"
|
||||
src="${image}" alt="${frappe.get_abbr(item_name)}"">
|
||||
src="${frappe.utils.escape_html(image)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}">
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div class="item-image item-abbr">${frappe.get_abbr(item_name)}</div>`;
|
||||
return `<div class="item-image item-abbr">${frappe.utils.escape_html(
|
||||
frappe.get_abbr(item_name)
|
||||
)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).parent().replaceWith(`<div class="item-image item-abbr">${item_abbr}</div>`);
|
||||
$($img)
|
||||
.parent()
|
||||
.replaceWith(`<div class="item-image item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
update_selector_value_in_cart_item(selector, value, item) {
|
||||
const $item_to_update = this.get_cart_item(item);
|
||||
$item_to_update.attr(`data-${selector}`, escape(value));
|
||||
$item_to_update.attr(`data-${selector}`, value);
|
||||
}
|
||||
|
||||
toggle_checkout_btn(show_checkout) {
|
||||
@@ -892,8 +905,8 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="customer-display">
|
||||
${this.get_customer_image()}
|
||||
<div class="customer-name-desc">
|
||||
<div class="customer-name">${customer_name}</div>
|
||||
<div class="customer-desc">${customer}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
|
||||
<div class="customer-desc">${frappe.utils.escape_html(customer)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customer-fields-container">
|
||||
@@ -980,6 +993,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
customer: current_customer,
|
||||
value: this.value,
|
||||
},
|
||||
freeze: true,
|
||||
callback: (r) => {
|
||||
if (!r.exc) {
|
||||
me.customer_info[this.df.fieldname] = this.value;
|
||||
@@ -1029,9 +1043,11 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
};
|
||||
|
||||
transaction_container.append(
|
||||
`<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
|
||||
`<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(
|
||||
invoice.name
|
||||
)}">
|
||||
<div class="invoice-name-date">
|
||||
<div class="invoice-name">${invoice.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
|
||||
<div class="invoice-date">${posting_datetime}</div>
|
||||
</div>
|
||||
<div class="invoice-total-status">
|
||||
@@ -1039,7 +1055,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
|
||||
</div>
|
||||
<div class="invoice-status">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status]}">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status] || ""}">
|
||||
<span>${__(invoice.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -128,25 +128,27 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
return ``;
|
||||
}
|
||||
|
||||
this.$item_name.html(item_name);
|
||||
this.$item_name.html(frappe.utils.escape_html(item_name));
|
||||
this.$item_description.html(get_description_html());
|
||||
this.$item_price.html(format_currency(price_list_rate, this.currency));
|
||||
if (!this.hide_images && image) {
|
||||
this.$item_image.html(
|
||||
`<img
|
||||
onerror="cur_pos.item_details.handle_broken_image(this)"
|
||||
class="h-full" src="${image}"
|
||||
alt="${frappe.get_abbr(item_name)}"
|
||||
class="h-full" src="${frappe.utils.escape_html(image)}"
|
||||
alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}"
|
||||
style="object-fit: cover;">`
|
||||
);
|
||||
} else {
|
||||
this.$item_image.html(`<div class="item-abbr">${frappe.get_abbr(item_name)}</div>`);
|
||||
this.$item_image.html(
|
||||
`<div class="item-abbr">${frappe.utils.escape_html(frappe.get_abbr(item_name))}</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).replaceWith(`<div class="item-abbr">${item_abbr}</div>`);
|
||||
$($img).replaceWith(`<div class="item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
render_discount_dom(item) {
|
||||
|
||||
@@ -107,39 +107,45 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
<div class="flex items-center justify-center border-b-grey text-6xl text-grey-100" style="height:8rem; min-height:8rem">
|
||||
<img
|
||||
onerror="cur_pos.item_selector.handle_broken_image(this)"
|
||||
class="h-full item-img" src="${item_image}"
|
||||
alt="${frappe.get_abbr(item.item_name)}"
|
||||
class="h-full item-img" src="${frappe.utils.escape_html(item_image)}"
|
||||
alt="${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}"
|
||||
>
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div class="item-qty-pill">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span>
|
||||
</div>
|
||||
<div class="item-display abbr">${frappe.get_abbr(item.item_name)}</div>`;
|
||||
<div class="item-display abbr">${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="item-wrapper"
|
||||
data-item-code="${escape(item.item_code)}" data-serial-no="${escape(serial_no)}"
|
||||
data-batch-no="${escape(batch_no)}" data-uom="${escape(uom)}"
|
||||
data-rate="${escape(price_list_rate || 0)}"
|
||||
data-stock-uom="${escape(item.stock_uom)}"
|
||||
title="${item.item_name}">
|
||||
data-item-code="${frappe.utils.escape_html(item.item_code)}" data-serial-no="${frappe.utils.escape_html(
|
||||
serial_no
|
||||
)}"
|
||||
data-batch-no="${frappe.utils.escape_html(batch_no)}" data-uom="${frappe.utils.escape_html(uom)}"
|
||||
data-rate="${frappe.utils.escape_html(price_list_rate || 0)}"
|
||||
data-stock-uom="${frappe.utils.escape_html(item.stock_uom)}"
|
||||
title="${frappe.utils.escape_html(item.item_name)}">
|
||||
|
||||
${get_item_image_html()}
|
||||
|
||||
<div class="item-detail">
|
||||
<div class="item-name">
|
||||
${frappe.ellipsis(item.item_name, 18)}
|
||||
${frappe.utils.escape_html(frappe.ellipsis(item.item_name, 18))}
|
||||
</div>
|
||||
<div class="item-rate">${format_currency(price_list_rate, item.currency, precision) || 0} / ${uom}</div>
|
||||
<div class="item-rate">${
|
||||
format_currency(price_list_rate, item.currency, precision) || 0
|
||||
} / ${frappe.utils.escape_html(uom)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).parent().replaceWith(`<div class="item-display abbr">${item_abbr}</div>`);
|
||||
$($img)
|
||||
.parent()
|
||||
.replaceWith(`<div class="item-display abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
make_search_bar() {
|
||||
@@ -252,14 +258,13 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
|
||||
this.$component.on("click", ".item-wrapper", function () {
|
||||
const $item = $(this);
|
||||
const item_code = unescape($item.attr("data-item-code"));
|
||||
let batch_no = unescape($item.attr("data-batch-no"));
|
||||
let serial_no = unescape($item.attr("data-serial-no"));
|
||||
let uom = unescape($item.attr("data-uom"));
|
||||
let rate = unescape($item.attr("data-rate"));
|
||||
let stock_uom = unescape($item.attr("data-stock-uom"));
|
||||
const item_code = $item.attr("data-item-code");
|
||||
let batch_no = $item.attr("data-batch-no");
|
||||
let serial_no = $item.attr("data-serial-no");
|
||||
let uom = $item.attr("data-uom");
|
||||
let rate = $item.attr("data-rate");
|
||||
let stock_uom = $item.attr("data-stock-uom");
|
||||
|
||||
// escape(undefined) returns "undefined" then unescape returns "undefined"
|
||||
batch_no = batch_no === "undefined" ? undefined : batch_no;
|
||||
serial_no = serial_no === "undefined" ? undefined : serial_no;
|
||||
uom = uom === "undefined" ? undefined : uom;
|
||||
|
||||
@@ -38,7 +38,7 @@ erpnext.PointOfSale.PastOrderList = class {
|
||||
});
|
||||
const me = this;
|
||||
this.$invoices_container.on("click", ".invoice-wrapper", function () {
|
||||
const invoice_name = unescape($(this).attr("data-invoice-name"));
|
||||
const invoice_name = $(this).attr("data-invoice-name");
|
||||
|
||||
me.events.open_invoice_data(invoice_name);
|
||||
});
|
||||
@@ -99,14 +99,14 @@ erpnext.PointOfSale.PastOrderList = class {
|
||||
const posting_datetime = frappe.datetime.str_to_user(
|
||||
invoice.posting_date + " " + invoice.posting_time
|
||||
);
|
||||
return `<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
|
||||
return `<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(invoice.name)}">
|
||||
<div class="invoice-name-date">
|
||||
<div class="invoice-name">${invoice.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
|
||||
<div class="invoice-date">
|
||||
<svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
${frappe.ellipsis(invoice.customer_name, 20)}
|
||||
${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="invoice-total-status">
|
||||
|
||||
@@ -81,23 +81,27 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
|
||||
return `<div class="left-section">
|
||||
<div class="customer-section">
|
||||
<div class="customer-name">${doc.customer_name}</div>
|
||||
${is_customer_naming_by_customer_name ? `<div class="customer-code">${doc.customer}</div>` : ""}
|
||||
<div class="customer-email">${this.customer_email}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(doc.customer_name)}</div>
|
||||
${
|
||||
is_customer_naming_by_customer_name
|
||||
? `<div class="customer-code">${frappe.utils.escape_html(doc.customer)}</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="customer-email">${frappe.utils.escape_html(this.customer_email)}</div>
|
||||
</div>
|
||||
<div class="cashier">${__("Sold by")}: ${doc.owner}</div>
|
||||
<div class="cashier">${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}</div>
|
||||
</div>
|
||||
<div class="right-section">
|
||||
<div class="paid-amount">${format_currency(doc.paid_amount, doc.currency)}</div>
|
||||
<div class="invoice-name">${doc.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(doc.name)}</div>
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color}"><span>${__(doc.status)}</span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
get_item_html(doc, item_data) {
|
||||
return `<div class="item-row-wrapper">
|
||||
<div class="item-name">${item_data.item_name}</div>
|
||||
<div class="item-qty">${item_data.qty || 0} ${item_data.uom}</div>
|
||||
<div class="item-name">${frappe.utils.escape_html(item_data.item_name)}</div>
|
||||
<div class="item-qty">${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</div>
|
||||
<div class="item-rate-disc">${get_rate_discount_html()}</div>
|
||||
</div>`;
|
||||
|
||||
@@ -139,10 +143,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
.map((t) => {
|
||||
// if tax rate is 0, don't print it.
|
||||
const description = /[0-9]+/.test(t.description)
|
||||
? t.description
|
||||
? frappe.utils.escape_html(t.description)
|
||||
: t.rate != 0
|
||||
? `${t.description} @ ${t.rate}%`
|
||||
: t.description;
|
||||
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
|
||||
: frappe.utils.escape_html(t.description);
|
||||
return `
|
||||
<div class="tax-row">
|
||||
<div class="tax-label">${description}</div>
|
||||
|
||||
@@ -408,8 +408,10 @@ erpnext.PointOfSale.Payment = class {
|
||||
|
||||
return `
|
||||
<div class="payment-mode-wrapper">
|
||||
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${payment_type}">
|
||||
${p.mode_of_payment}
|
||||
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${frappe.utils.escape_html(
|
||||
payment_type
|
||||
)}">
|
||||
${frappe.utils.escape_html(p.mode_of_payment)}
|
||||
<div class="${mode}-amount pay-amount">${amount}</div>
|
||||
<div class="${mode} mode-of-payment-control"></div>
|
||||
</div>
|
||||
@@ -544,7 +546,7 @@ erpnext.PointOfSale.Payment = class {
|
||||
<div class="mode-of-payment loyalty-card" data-mode="loyalty-amount" data-payment-type="loyalty-amount">
|
||||
Redeem Loyalty Points
|
||||
<div class="loyalty-amount-amount pay-amount">${amount}</div>
|
||||
<div class="loyalty-amount-name">${loyalty_program}</div>
|
||||
<div class="loyalty-amount-name">${frappe.utils.escape_html(loyalty_program)}</div>
|
||||
<div class="loyalty-amount mode-of-payment-control"></div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -11,9 +11,9 @@ field_map = {
|
||||
"name",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"pincode",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"is_primary_address",
|
||||
],
|
||||
|
||||
@@ -138,12 +138,30 @@ class Analytics:
|
||||
self.get_sales_transactions_based_on_project()
|
||||
self.get_rows()
|
||||
|
||||
def _get_permitted_parent_names(self):
|
||||
return frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=["name"],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
def get_sales_transactions_based_on_order_type(self):
|
||||
if self.filters["value_quantity"] == "Value":
|
||||
value_field = "base_net_total"
|
||||
else:
|
||||
value_field = "total_qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.get_teams()
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
|
||||
self.entries = (
|
||||
@@ -153,12 +171,7 @@ class Analytics:
|
||||
doctype[self.date_field],
|
||||
doctype[value_field].as_("value_field"),
|
||||
)
|
||||
.where(
|
||||
(doctype.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
& (IfNull(doctype.order_type, "") != "")
|
||||
)
|
||||
.where((doctype.name.isin(permitted_names)) & (IfNull(doctype.order_type, "") != ""))
|
||||
.orderby(doctype.order_type)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -186,8 +199,10 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, entity_name, value_field, self.date_field],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
self.entity_names = {}
|
||||
@@ -200,6 +215,12 @@ class Analytics:
|
||||
else:
|
||||
value_field = "stock_qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.entity_names = {}
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
doctype_item = DocType(f"{self.filters.doc_type} Item")
|
||||
|
||||
@@ -214,11 +235,7 @@ class Analytics:
|
||||
doctype_item[value_field].as_("value_field"),
|
||||
doctype[self.date_field],
|
||||
)
|
||||
.where(
|
||||
(doctype_item.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
)
|
||||
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
|
||||
).run(as_dict=True)
|
||||
|
||||
self.entity_names = {}
|
||||
@@ -248,7 +265,7 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity_field, value_field, self.date_field],
|
||||
filters=filters,
|
||||
@@ -261,6 +278,12 @@ class Analytics:
|
||||
else:
|
||||
value_field = "qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.get_groups()
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
doctype_item = DocType(f"{self.filters.doc_type} Item")
|
||||
|
||||
@@ -273,11 +296,7 @@ class Analytics:
|
||||
doctype_item[value_field].as_("value_field"),
|
||||
doctype[self.date_field],
|
||||
)
|
||||
.where(
|
||||
(doctype_item.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
)
|
||||
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
|
||||
).run(as_dict=True)
|
||||
|
||||
self.get_groups()
|
||||
@@ -300,8 +319,10 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, value_field, self.date_field],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def get_rows(self):
|
||||
|
||||
@@ -262,7 +262,15 @@
|
||||
},
|
||||
"Belgium VAT 12%": {
|
||||
"account_name": "VAT 12%",
|
||||
"tax_rate": 12
|
||||
"tax_rate": 12.00
|
||||
},
|
||||
"Belgium VAT 6%": {
|
||||
"account_name": "VAT 6%",
|
||||
"tax_rate": 6.00
|
||||
},
|
||||
"Belgium VAT 0%": {
|
||||
"account_name": "VAT 0%",
|
||||
"tax_rate": 0.00
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4115,9 +4123,14 @@
|
||||
},
|
||||
|
||||
"Japan": {
|
||||
"Japan Tax": {
|
||||
"account_name": "CT",
|
||||
"tax_rate": 5.00
|
||||
"Japan Tax 10%": {
|
||||
"account_name": "CT 10%",
|
||||
"tax_rate": 10.00,
|
||||
"default": 1
|
||||
},
|
||||
"Japan Tax 8%": {
|
||||
"account_name": "CT 8%",
|
||||
"tax_rate": 8.00
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from frappe.model.naming import make_autoname, revert_series_if_last
|
||||
from frappe.query_builder.functions import CurDate, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
from frappe.utils.data import add_days
|
||||
from frappe.utils.jinja import render_template
|
||||
|
||||
|
||||
class UnableToSelectBatchError(frappe.ValidationError):
|
||||
@@ -225,10 +224,8 @@ class Batch(Document):
|
||||
:return: The string that was generated.
|
||||
"""
|
||||
naming_series_prefix = _get_batch_prefix()
|
||||
# validate_template(naming_series_prefix)
|
||||
naming_series_prefix = render_template(str(naming_series_prefix), self.__dict__)
|
||||
key = _make_naming_series_key(naming_series_prefix)
|
||||
name = make_autoname(key)
|
||||
name = make_autoname(key, doc=self)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
@@ -506,6 +506,24 @@ class TestBatch(FrappeTestCase):
|
||||
if not use_naming_series:
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 0)
|
||||
|
||||
def test_naming_series_prefix_is_not_rendered_as_jinja(self):
|
||||
from frappe.model.naming import InvalidNamingSeriesError
|
||||
|
||||
stock_settings = frappe.get_single("Stock Settings")
|
||||
use_naming_series = cint(stock_settings.use_naming_series)
|
||||
original_prefix = stock_settings.naming_series_prefix
|
||||
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 1)
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "naming_series_prefix", "{{ 7*7 }}")
|
||||
|
||||
try:
|
||||
self.assertRaises(
|
||||
InvalidNamingSeriesError, self.make_new_batch, "_Test Stock Item For Batch SSTI"
|
||||
)
|
||||
finally:
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "naming_series_prefix", original_prefix)
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", use_naming_series)
|
||||
|
||||
def make_new_batch(self, item_name=None, batch_id=None, do_not_insert=0):
|
||||
batch = frappe.new_doc("Batch")
|
||||
item = self.make_batch_item(item_name)
|
||||
|
||||
@@ -263,8 +263,9 @@ def update_qty(bin_name, args):
|
||||
# actual qty is already updated by processing current voucher
|
||||
actual_qty = bin_details.actual_qty or 0.0
|
||||
|
||||
# actual qty is not up to date in case of backdated transaction
|
||||
if future_sle_exists(args):
|
||||
# actual qty is not up to date in case of backdated transactions
|
||||
# or when cancellations are the most recent SLE
|
||||
if future_sle_exists(args) or args.get("is_cancelled"):
|
||||
actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse"))
|
||||
|
||||
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))
|
||||
|
||||
@@ -15,7 +15,7 @@ from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.party import get_due_date
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.stock.stock_ledger import validate_reserved_stock
|
||||
@@ -1405,8 +1405,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
doctype: {
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse"],
|
||||
"field_map": {"shipping_address_name": "shipping_address"},
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
|
||||
},
|
||||
doctype + " Item": {
|
||||
"doctype": target_doctype + " Item",
|
||||
|
||||
@@ -740,14 +740,18 @@ $.extend(erpnext.item, {
|
||||
|
||||
if (!row.disabled) {
|
||||
if (row.numeric_values) {
|
||||
fieldtype = "Float";
|
||||
desc =
|
||||
"Min Value: " +
|
||||
row.from_range +
|
||||
" , Max Value: " +
|
||||
row.to_range +
|
||||
", in Increments of: " +
|
||||
row.increment;
|
||||
const all_are_int =
|
||||
flt(row.from_range) === cint(row.from_range) &&
|
||||
flt(row.to_range) === cint(row.to_range) &&
|
||||
flt(row.increment) === cint(row.increment);
|
||||
fieldtype = all_are_int ? "Int" : "Float";
|
||||
const df = { fieldtype };
|
||||
const options = all_are_int ? { inline: 1 } : { always_show_decimals: true, inline: 1 };
|
||||
desc = __("Min Value: {0}, Max Value: {1}, in Increments of: {2}", [
|
||||
frappe.format(row.from_range, df, options),
|
||||
frappe.format(row.to_range, df, options),
|
||||
frappe.format(row.increment, df, options),
|
||||
]);
|
||||
} else {
|
||||
fieldtype = "Data";
|
||||
desc = "";
|
||||
|
||||
@@ -1050,6 +1050,44 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
pr.cancel()
|
||||
|
||||
def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on DN (from Customer) must not leak into the mapped PR.
|
||||
"""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
|
||||
customer = "_Test Internal Customer 2"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
stock = make_purchase_receipt(warehouse="Stores - TCP1", company=company)
|
||||
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
customer=customer,
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
qty=1,
|
||||
rate=100,
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse="Work In Progress - TCP1",
|
||||
do_not_submit=True,
|
||||
)
|
||||
# Stamp customer-side party fields onto the DN
|
||||
dn.tax_category = "_Test Tax Category 2"
|
||||
dn.language = "ar"
|
||||
dn.submit()
|
||||
|
||||
pr = make_inter_company_purchase_receipt(dn.name)
|
||||
|
||||
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2")
|
||||
self.assertEqual(pr.tax_category or None, supplier.tax_category or None)
|
||||
self.assertEqual(pr.language or None, supplier.language or None)
|
||||
dn.cancel()
|
||||
stock.cancel()
|
||||
|
||||
def test_lcv_for_internal_transfer(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -2169,7 +2169,16 @@ def get_type_of_transaction(parent_doc, child_row):
|
||||
|
||||
|
||||
def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, warehouse=None) -> object:
|
||||
frappe.has_permission("Serial and Batch Bundle", "write", throw=True)
|
||||
doc = frappe.get_doc("Serial and Batch Bundle", bundle)
|
||||
|
||||
if doc.docstatus == 1:
|
||||
doc.throw_error_message(
|
||||
_("Serial and Batch Bundle {0} is submitted and its entries cannot be modified.").format(
|
||||
frappe.bold(bundle)
|
||||
)
|
||||
)
|
||||
|
||||
doc.voucher_detail_no = child_row.name
|
||||
doc.posting_date = parent_doc.posting_date
|
||||
doc.posting_time = parent_doc.posting_time
|
||||
@@ -2194,25 +2203,6 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_serial_or_batch(bundle_id, serial_no=None, batch_no=None):
|
||||
if batch_no and not serial_no:
|
||||
if qty := frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty"
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1
|
||||
)
|
||||
return
|
||||
|
||||
doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id)
|
||||
if not serial_no and not batch_no:
|
||||
return
|
||||
|
||||
doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1})
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def get_serial_and_batch_ledger(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
|
||||
@@ -734,6 +734,60 @@ class TestSerialandBatchBundle(FrappeTestCase):
|
||||
docstatus = frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus")
|
||||
self.assertEqual(docstatus, 2)
|
||||
|
||||
def test_submitted_bundle_entries_cannot_be_mutated(self):
|
||||
# A submitted Serial and Batch Bundle is the immutable source of truth for the stock
|
||||
# ledger, live batch availability and repost/valuation replay. update_serial_batch_no_ledgers
|
||||
# (which the whitelisted add_serial_batch_ledgers delegates to for an existing bundle) must
|
||||
# refuse to rebuild -- and thereby inflate -- the quantities of an already submitted bundle.
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
update_serial_batch_no_ledgers,
|
||||
)
|
||||
|
||||
item_code = make_item(
|
||||
properties={
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TAMPER-SBB-.#####",
|
||||
}
|
||||
).name
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
bundle = se.items[0].serial_and_batch_bundle
|
||||
self.assertEqual(frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus"), 1)
|
||||
|
||||
original = frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": bundle}, ["name", "batch_no", "qty"], as_dict=True
|
||||
)
|
||||
self.assertEqual(original.qty, 10)
|
||||
|
||||
# Attempt to forge the submitted bundle: keep the same batch but inflate qty. The guard
|
||||
# fires immediately after the bundle is loaded (docstatus check), so child_row / parent_doc
|
||||
# only need the minimal fields the function reads.
|
||||
tampered_entries = [{"batch_no": original.batch_no, "qty": 1000}]
|
||||
child_row = frappe._dict({"name": se.items[0].name})
|
||||
parent_doc = frappe._dict({"posting_date": today(), "posting_time": nowtime()})
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
update_serial_batch_no_ledgers,
|
||||
bundle,
|
||||
tampered_entries,
|
||||
child_row,
|
||||
parent_doc,
|
||||
)
|
||||
|
||||
# The on-disk quantity must be untouched by the rejected mutation attempt.
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Entry", original.name, "qty"),
|
||||
10,
|
||||
)
|
||||
|
||||
def test_batch_duplicate_entry(self):
|
||||
item_code = make_item(properties={"has_batch_no": 1}).name
|
||||
|
||||
|
||||
@@ -228,6 +228,13 @@ class StockEntry(StockController):
|
||||
|
||||
self.validate_warehouse()
|
||||
self.validate_with_material_request()
|
||||
|
||||
# Disassembly rows are fully derived from the source manufacture entry / work order;
|
||||
# verify the posted stock quantities have not been tampered with (raw-material minting).
|
||||
# Must run after set_transfer_qty() so row.transfer_qty reflects qty * conversion_factor.
|
||||
if self.purpose == "Disassemble":
|
||||
self.validate_disassembly_quantities()
|
||||
|
||||
self.validate_batch()
|
||||
self.validate_inspection()
|
||||
self.validate_fg_completed_qty()
|
||||
@@ -246,6 +253,7 @@ class StockEntry(StockController):
|
||||
self.calculate_rate_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_component_and_quantities()
|
||||
self.validate_finished_good_serial_batch_for_work_order()
|
||||
|
||||
if not self.get("purpose") == "Manufacture":
|
||||
# ignore scrap item wh difference and empty source/target wh
|
||||
@@ -256,6 +264,83 @@ class StockEntry(StockController):
|
||||
self.validate_same_source_target_warehouse_during_material_transfer()
|
||||
self.validate_raw_materials_exists()
|
||||
|
||||
def validate_finished_good_serial_batch_for_work_order(self):
|
||||
if not (
|
||||
self.work_order
|
||||
and self.pro_doc
|
||||
and self.pro_doc.get("track_semi_finished_goods") != 1
|
||||
and cint(
|
||||
frappe.db.get_single_value(
|
||||
"Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True
|
||||
)
|
||||
)
|
||||
and (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no)
|
||||
):
|
||||
return
|
||||
|
||||
for row in self.items:
|
||||
if not row.is_finished_item:
|
||||
continue
|
||||
|
||||
if self.check_invalid_serial_batch_nos_for_finished_good_item(row):
|
||||
self.reset_serial_batch_on_fg_row(row)
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"Row {0}: Serial/Batch has been reset to values linked with Work Order {1}"
|
||||
" because the previously selected serial/batch does not belong to this Work Order."
|
||||
).format(row.idx, frappe.bold(self.work_order))
|
||||
)
|
||||
|
||||
def check_invalid_serial_batch_nos_for_finished_good_item(self, row) -> bool:
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos_from_bundle
|
||||
|
||||
if self.pro_doc.has_serial_no:
|
||||
serial_nos = get_serial_nos(row.serial_no) if row.serial_no else []
|
||||
if not serial_nos and row.serial_and_batch_bundle:
|
||||
serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle)
|
||||
if serial_nos:
|
||||
valid_serial_nos = frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"name": ("in", serial_nos), "work_order": self.work_order},
|
||||
pluck="name",
|
||||
)
|
||||
return bool(set(serial_nos) - set(valid_serial_nos))
|
||||
else:
|
||||
return True
|
||||
|
||||
if self.pro_doc.has_batch_no:
|
||||
batch_nos = [row.batch_no] if row.batch_no else []
|
||||
if not batch_nos and row.serial_and_batch_bundle:
|
||||
batch_nos = list(get_batches_from_bundle(row.serial_and_batch_bundle).keys())
|
||||
if batch_nos:
|
||||
valid_batch_nos = frappe.get_all(
|
||||
"Batch",
|
||||
filters={"name": ("in", batch_nos), "reference_name": self.work_order},
|
||||
pluck="name",
|
||||
)
|
||||
return bool(set(batch_nos) - set(valid_batch_nos))
|
||||
else:
|
||||
return True
|
||||
|
||||
def reset_serial_batch_on_fg_row(self, row):
|
||||
item_details = frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"t_warehouse": row.t_warehouse,
|
||||
"qty": row.qty,
|
||||
}
|
||||
)
|
||||
|
||||
row.serial_no = None
|
||||
row.batch_no = None
|
||||
row.serial_and_batch_bundle = None
|
||||
|
||||
if self.pro_doc.has_serial_no:
|
||||
self.set_serial_no_batch_for_finished_good()
|
||||
elif self.pro_doc.has_batch_no:
|
||||
self.set_batchwise_finished_goods(item_details, None, existing_row=row)
|
||||
|
||||
def validate_repack_entry(self):
|
||||
if self.purpose != "Repack":
|
||||
return
|
||||
@@ -602,7 +687,7 @@ class StockEntry(StockController):
|
||||
amount += additional_cost_amt
|
||||
project = frappe.get_doc("Project", self.project)
|
||||
project.total_consumed_material_cost = amount
|
||||
project.save()
|
||||
project.save(ignore_permissions=True)
|
||||
|
||||
def validate_item(self):
|
||||
stock_items = self.get_stock_items()
|
||||
@@ -853,6 +938,93 @@ class StockEntry(StockController):
|
||||
title=_("Excess Disassembly"),
|
||||
)
|
||||
|
||||
def validate_disassembly_quantities(self):
|
||||
self.validate_finished_good_consumption()
|
||||
self.validate_materials_against_source()
|
||||
|
||||
def validate_finished_good_consumption(self):
|
||||
"""The finished good consumed (in stock UOM) must equal the quantity to disassemble."""
|
||||
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
|
||||
tolerance = _qty_tolerance(precision)
|
||||
|
||||
fg_stock_qty = sum(flt(row.transfer_qty) for row in self.items if row.is_finished_item)
|
||||
fg_completed_qty = flt(self.fg_completed_qty)
|
||||
|
||||
if abs(flt(fg_stock_qty, precision) - flt(fg_completed_qty, precision)) > tolerance:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Finished good quantity being consumed ({0} in stock UOM) must equal the quantity "
|
||||
"to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the "
|
||||
"finished good row."
|
||||
).format(flt(fg_stock_qty, precision), flt(fg_completed_qty, precision)),
|
||||
title=_("Invalid Disassembly Quantity"),
|
||||
)
|
||||
|
||||
def validate_materials_against_source(self):
|
||||
"""Every non-finished-good row's posted stock qty must equal the source qty x scale."""
|
||||
scale_factor = self._get_disassembly_scale_factor()
|
||||
if not scale_factor:
|
||||
# Standalone BOM disassembly: no source entry to scale against. The finished-good
|
||||
# invariant above still applies; raw-material amounts come from the BOM.
|
||||
return
|
||||
|
||||
source_rows = self.get_items_from_manufacture_stock_entry()
|
||||
source_by_name = {row.name: row for row in source_rows if row.get("name")}
|
||||
source_by_item = defaultdict(float)
|
||||
for row in source_rows:
|
||||
source_by_item[row.item_code] += flt(row.transfer_qty)
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
|
||||
tolerance = _qty_tolerance(precision)
|
||||
|
||||
for row in self.items:
|
||||
if row.is_finished_item:
|
||||
continue # covered by validate_finished_good_consumption
|
||||
|
||||
if row.ste_detail and row.ste_detail in source_by_name:
|
||||
expected = flt(source_by_name[row.ste_detail].transfer_qty) * scale_factor
|
||||
elif row.item_code in source_by_item:
|
||||
expected = source_by_item[row.item_code] * scale_factor
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Item {1} is not part of the source manufacture entry and cannot be "
|
||||
"added to this disassembly."
|
||||
).format(row.idx, frappe.bold(row.item_code)),
|
||||
title=_("Invalid Disassembly Item"),
|
||||
)
|
||||
|
||||
if abs(flt(row.transfer_qty, precision) - flt(expected, precision)) > tolerance:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity "
|
||||
"derived from the source ({3}). Do not change the UOM, conversion factor or "
|
||||
"quantity of disassembly rows."
|
||||
).format(
|
||||
row.idx,
|
||||
frappe.bold(row.item_code),
|
||||
flt(row.transfer_qty, precision),
|
||||
flt(expected, precision),
|
||||
),
|
||||
title=_("Invalid Disassembly Quantity"),
|
||||
)
|
||||
|
||||
def _get_disassembly_scale_factor(self) -> float:
|
||||
disassemble_qty = flt(self.fg_completed_qty)
|
||||
if self.source_stock_entry:
|
||||
source_fg_qty = flt(
|
||||
frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
|
||||
)
|
||||
elif self.work_order:
|
||||
source_fg_qty = flt(frappe.db.get_value("Work Order", self.work_order, "produced_qty"))
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
if not source_fg_qty:
|
||||
return 0.0
|
||||
|
||||
return disassemble_qty / source_fg_qty
|
||||
|
||||
def check_if_operations_completed(self):
|
||||
"""Check if Time Sheets are completed against before manufacturing to capture operating costs."""
|
||||
prod_order = frappe.get_doc("Work Order", self.work_order)
|
||||
@@ -2465,15 +2637,16 @@ class StockEntry(StockController):
|
||||
else:
|
||||
self.add_finished_goods(args, item)
|
||||
|
||||
def set_batchwise_finished_goods(self, args, item):
|
||||
def set_batchwise_finished_goods(self, args, item, existing_row=None):
|
||||
batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item)
|
||||
|
||||
if not batches:
|
||||
self.add_finished_goods(args, item)
|
||||
if not existing_row:
|
||||
self.add_finished_goods(args, item)
|
||||
else:
|
||||
self.add_batchwise_finished_good(batches, args, item)
|
||||
self.add_batchwise_finished_good(batches, args, item, existing_row=existing_row)
|
||||
|
||||
def add_batchwise_finished_good(self, batches, args, item):
|
||||
def add_batchwise_finished_good(self, batches, args, item, existing_row=None):
|
||||
qty = flt(self.fg_completed_qty)
|
||||
row = frappe._dict({"batches_to_be_consume": defaultdict(float)})
|
||||
|
||||
@@ -2482,7 +2655,7 @@ class StockEntry(StockController):
|
||||
if not row.batches_to_be_consume:
|
||||
return
|
||||
|
||||
id = create_serial_and_batch_bundle(
|
||||
_id = create_serial_and_batch_bundle(
|
||||
self,
|
||||
row,
|
||||
frappe._dict(
|
||||
@@ -2492,9 +2665,13 @@ class StockEntry(StockController):
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
args["serial_and_batch_bundle"] = id
|
||||
self.add_finished_goods(args, item)
|
||||
if existing_row:
|
||||
existing_row.serial_and_batch_bundle = _id
|
||||
existing_row.use_serial_batch_fields = 0
|
||||
else:
|
||||
args["serial_and_batch_bundle"] = _id
|
||||
args["use_serial_batch_fields"] = 0
|
||||
self.add_finished_goods(args, item)
|
||||
|
||||
def add_finished_goods(self, args, item):
|
||||
self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no)
|
||||
@@ -3304,6 +3481,12 @@ def move_sample_to_retention_warehouse(company, items):
|
||||
return stock_entry.as_dict()
|
||||
|
||||
|
||||
def _qty_tolerance(precision: int) -> float:
|
||||
"""One unit at the column's precision -- absorbs float rounding without letting a real
|
||||
(whole-unit) quantity divergence slip through."""
|
||||
return 1.0 / (10**precision)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_in_entry(source_name, target_doc=None):
|
||||
def set_missing_values(source, target):
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
frappe.listview_settings["Stock Entry"] = {
|
||||
add_fields: [
|
||||
"`tabStock Entry`.`from_warehouse`",
|
||||
"`tabStock Entry`.`to_warehouse`",
|
||||
"`tabStock Entry`.`purpose`",
|
||||
"`tabStock Entry`.`work_order`",
|
||||
"`tabStock Entry`.`bom_no`",
|
||||
"`tabStock Entry`.`is_return`",
|
||||
"from_warehouse",
|
||||
"to_warehouse",
|
||||
"purpose",
|
||||
"work_order",
|
||||
"bom_no",
|
||||
"is_return",
|
||||
"add_to_transit",
|
||||
"per_transferred",
|
||||
],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.is_return === 1 && doc.purpose === "Material Transfer for Manufacture") {
|
||||
@@ -16,6 +18,17 @@ frappe.listview_settings["Stock Entry"] = {
|
||||
];
|
||||
} else if (doc.docstatus === 0) {
|
||||
return [__("Draft"), "red", "docstatus,=,0"];
|
||||
} else if (
|
||||
doc.docstatus === 1 &&
|
||||
doc.add_to_transit === 1 &&
|
||||
doc.purpose === "Material Transfer" &&
|
||||
doc.per_transferred < 100
|
||||
) {
|
||||
return [
|
||||
__("In Transit"),
|
||||
"yellow",
|
||||
"docstatus,=,1|add_to_transit,=,1|purpose,=,Material Transfer|per_transferred,<,100",
|
||||
];
|
||||
} else if (doc.purpose === "Send to Warehouse" && doc.per_transferred < 100) {
|
||||
// not delivered & overdue
|
||||
return [__("Goods In Transit"), "grey", "per_transferred,<,100"];
|
||||
|
||||
@@ -235,7 +235,15 @@ class TestStockEntry(FrappeTestCase):
|
||||
self.assertEqual(transit_entry.name, end_transit_entry.items[0].against_stock_entry)
|
||||
self.assertEqual(transit_entry.items[0].name, end_transit_entry.items[0].ste_detail)
|
||||
|
||||
# create add to transit
|
||||
transit_entry.reload()
|
||||
self.assertEqual(transit_entry.per_transferred, 0)
|
||||
|
||||
end_transit_entry.to_warehouse = "Test To Warehouse - _TC"
|
||||
end_transit_entry.items[0].t_warehouse = "Test To Warehouse - _TC"
|
||||
end_transit_entry.save().submit()
|
||||
|
||||
transit_entry.reload()
|
||||
self.assertEqual(transit_entry.per_transferred, 100)
|
||||
|
||||
def test_material_receipt_gl_entry(self):
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
@@ -2236,6 +2244,47 @@ class TestStockEntry(FrappeTestCase):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
def test_disassemble_blocks_finished_good_qty_tampering(self):
|
||||
# A disassembly consuming N finished goods must consume exactly N (in stock UOM).
|
||||
# Switching the finished-good row to a larger UOM with a tiny conversion_factor previously
|
||||
# let a user consume ~0 finished goods while still producing the full raw materials --
|
||||
# minting inventory. The quantity invariant must reject this.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
fg_item = make_item("_Disassemble Mint FG", properties={"is_stock_item": 1}).name
|
||||
rm_item1 = make_item("_Disassemble Mint RM1", properties={"is_stock_item": 1}).name
|
||||
rm_item2 = make_item("_Disassemble Mint RM2", properties={"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Give the finished good a non-stock UOM. When uom == stock_uom the system resets the
|
||||
# conversion_factor to 1, so the tamper is only possible (and worth guarding) on a
|
||||
# non-stock UOM, where the user-supplied conversion_factor is preserved.
|
||||
if not frappe.db.get_value("UOM Conversion Detail", {"parent": fg_item, "uom": "Box"}):
|
||||
item_doc = frappe.get_doc("Item", fg_item)
|
||||
item_doc.append("uoms", {"uom": "Box", "conversion_factor": 0.01})
|
||||
item_doc.save(ignore_permissions=True)
|
||||
|
||||
make_stock_entry(item_code=fg_item, target=warehouse, qty=100, purpose="Material Receipt")
|
||||
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name
|
||||
|
||||
se = make_stock_entry(item_code=fg_item, qty=100, purpose="Disassemble", do_not_save=True)
|
||||
se.from_bom = 1
|
||||
se.use_multi_level_bom = 1
|
||||
se.bom_no = bom_no
|
||||
se.fg_completed_qty = 100
|
||||
se.from_warehouse = warehouse
|
||||
se.to_warehouse = warehouse
|
||||
se.get_items()
|
||||
|
||||
# Tamper the finished-good row: a tiny conversion factor on the larger UOM means only
|
||||
# 100 * 0.01 = 1 unit is actually consumed, while raw materials are still produced at full
|
||||
# quantity. The finished-good consumption invariant must reject the save.
|
||||
fg_row = next(d for d in se.items if d.is_finished_item)
|
||||
fg_row.uom = "Box"
|
||||
fg_row.conversion_factor = 0.01
|
||||
|
||||
self.assertRaises(frappe.ValidationError, se.save)
|
||||
|
||||
def test_raw_material_missing_validation(self):
|
||||
original_value = frappe.db.get_single_value("Manufacturing Settings", "material_consumption")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_copy": 1,
|
||||
"autoname": "MAT-SLE-.YYYY.-.#####",
|
||||
"creation": "2013-01-29 19:25:42",
|
||||
@@ -204,7 +205,7 @@
|
||||
{
|
||||
"fieldname": "valuation_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Valuation Rate",
|
||||
"label": "Average Rate",
|
||||
"oldfieldname": "valuation_rate",
|
||||
"oldfieldtype": "Currency",
|
||||
"options": "Company:company:default_currency",
|
||||
@@ -362,11 +363,11 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-04 09:59:15.546556",
|
||||
"modified": "2026-05-26 19:07:43.537450",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Ledger Entry",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -9,14 +9,14 @@ from frappe.utils import add_to_date, cint, cstr, flt, get_datetime, now
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import get_company_default
|
||||
from erpnext.controllers.stock_controller import StockController, create_repost_item_valuation_entry
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_available_serial_nos,
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_stock_balance
|
||||
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
|
||||
|
||||
|
||||
class OpeningEntryAccountError(frappe.ValidationError):
|
||||
@@ -842,22 +842,6 @@ class StockReconciliation(StockController):
|
||||
|
||||
sl_entries.append(args)
|
||||
|
||||
def update_valuation_rate_for_serial_no(self):
|
||||
for d in self.items:
|
||||
if not d.serial_no:
|
||||
continue
|
||||
|
||||
serial_nos = get_serial_nos(d.serial_no)
|
||||
self.update_valuation_rate_for_serial_nos(d, serial_nos)
|
||||
|
||||
def update_valuation_rate_for_serial_nos(self, row, serial_nos):
|
||||
valuation_rate = row.valuation_rate if self.docstatus == 1 else row.current_valuation_rate
|
||||
if valuation_rate is None:
|
||||
return
|
||||
|
||||
for d in serial_nos:
|
||||
frappe.db.set_value("Serial No", d, "purchase_rate", valuation_rate)
|
||||
|
||||
def get_sle_for_items(self, row, serial_nos=None, current_bundle=True):
|
||||
"""Insert Stock Ledger Entries"""
|
||||
|
||||
@@ -1011,11 +995,6 @@ class StockReconciliation(StockController):
|
||||
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
|
||||
d.amount_difference = flt(d.amount) - flt(d.current_amount)
|
||||
|
||||
def get_items_for(self, warehouse):
|
||||
self.items = []
|
||||
for item in get_items(warehouse, self.posting_date, self.posting_time, self.company):
|
||||
self.append("items", item)
|
||||
|
||||
def submit(self):
|
||||
if len(self.items) > 100:
|
||||
msgprint(
|
||||
@@ -1038,145 +1017,6 @@ class StockReconciliation(StockController):
|
||||
else:
|
||||
self._cancel()
|
||||
|
||||
def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation):
|
||||
if row.current_qty == 0:
|
||||
return
|
||||
|
||||
new_sle = frappe.get_doc(self.get_sle_for_items(row))
|
||||
new_sle.actual_qty = row.current_qty * -1
|
||||
new_sle.valuation_rate = row.current_valuation_rate
|
||||
new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle
|
||||
new_sle.flags.ignore_permissions = 1
|
||||
new_sle.submit()
|
||||
|
||||
creation = add_to_date(sle_creation, seconds=-1)
|
||||
new_sle.db_set("creation", creation)
|
||||
|
||||
if not frappe.db.exists(
|
||||
"Repost Item Valuation",
|
||||
{"item": row.item_code, "warehouse": row.warehouse, "docstatus": 1, "status": "Queued"},
|
||||
):
|
||||
create_repost_item_valuation_entry(
|
||||
{
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"company": self.company,
|
||||
"allow_negative_stock": 1,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
|
||||
def has_negative_stock_allowed(self):
|
||||
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
|
||||
if allow_negative_stock:
|
||||
return True
|
||||
|
||||
if any(
|
||||
((d.serial_and_batch_bundle or d.batch_no) and flt(d.qty) == flt(d.current_qty))
|
||||
for d in self.items
|
||||
):
|
||||
allow_negative_stock = True
|
||||
|
||||
return allow_negative_stock
|
||||
|
||||
def get_current_qty_for_serial_or_batch(self, row, sle_creation):
|
||||
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
|
||||
current_qty = 0.0
|
||||
if doc.has_serial_no:
|
||||
current_qty = self.get_current_qty_for_serial_nos(doc, sle_creation)
|
||||
elif doc.has_batch_no:
|
||||
current_qty = self.get_current_qty_for_batch_nos(doc, sle_creation)
|
||||
|
||||
return abs(current_qty)
|
||||
|
||||
def get_current_qty_for_serial_nos(self, doc, sle_creation):
|
||||
serial_nos_details = get_available_serial_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": doc.item_code,
|
||||
"warehouse": doc.warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
"creation": sle_creation,
|
||||
"voucher_no": self.name,
|
||||
"ignore_warehouse": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if not serial_nos_details:
|
||||
return 0.0
|
||||
|
||||
doc.delete_serial_batch_entries()
|
||||
current_qty = 0.0
|
||||
for serial_no_row in serial_nos_details:
|
||||
current_qty += 1
|
||||
doc.append(
|
||||
"entries",
|
||||
{
|
||||
"serial_no": serial_no_row.serial_no,
|
||||
"qty": -1,
|
||||
"warehouse": doc.warehouse,
|
||||
"batch_no": serial_no_row.batch_no,
|
||||
},
|
||||
)
|
||||
|
||||
doc.set_incoming_rate(save=True)
|
||||
doc.calculate_qty_and_amount(save=True)
|
||||
doc.db_update_all()
|
||||
|
||||
return current_qty
|
||||
|
||||
def get_current_qty_for_batch_nos(self, doc, sle_creation):
|
||||
current_qty = 0.0
|
||||
precision = doc.entries[0].precision("qty")
|
||||
for d in doc.entries:
|
||||
qty = (
|
||||
get_batch_qty(
|
||||
d.batch_no,
|
||||
doc.warehouse,
|
||||
creation=sle_creation,
|
||||
posting_date=doc.posting_date,
|
||||
posting_time=doc.posting_time,
|
||||
ignore_voucher_nos=[doc.voucher_no],
|
||||
for_stock_levels=True,
|
||||
consider_negative_batches=True,
|
||||
do_not_check_future_batches=True,
|
||||
)
|
||||
or 0
|
||||
) * -1
|
||||
|
||||
if flt(d.qty, precision) != flt(qty, precision):
|
||||
d.db_set("qty", qty)
|
||||
|
||||
current_qty += qty
|
||||
|
||||
return current_qty
|
||||
|
||||
|
||||
def get_batch_qty_for_stock_reco(
|
||||
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation
|
||||
):
|
||||
qty = (
|
||||
get_batch_qty(
|
||||
batch_no,
|
||||
warehouse,
|
||||
item_code,
|
||||
creation=sle_creation,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
ignore_voucher_nos=[voucher_no],
|
||||
for_stock_levels=True,
|
||||
consider_negative_batches=True,
|
||||
do_not_check_future_batches=True,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
return flt(qty)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False):
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Abs, Count
|
||||
from frappe.utils import cint, date_diff, flt, get_datetime
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
from erpnext.stock.valuation import round_off_if_near_zero
|
||||
|
||||
Filters = frappe._dict
|
||||
@@ -936,9 +937,7 @@ class FIFOSlots:
|
||||
)
|
||||
)
|
||||
|
||||
for field in ["item_code"]:
|
||||
if self.filters.get(field):
|
||||
query = query.where(sle[field] == self.filters.get(field))
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
if self.filters.get("warehouse"):
|
||||
query = self._get_warehouse_conditions(sle, query)
|
||||
@@ -985,9 +984,7 @@ class FIFOSlots:
|
||||
)
|
||||
)
|
||||
|
||||
for field in ["item_code"]:
|
||||
if self.filters.get(field):
|
||||
query = query.where(sle[field] == self.filters.get(field))
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
if self.filters.get("warehouse"):
|
||||
query = self._get_warehouse_conditions(sle, query)
|
||||
@@ -1020,27 +1017,25 @@ class FIFOSlots:
|
||||
"has_batch_no",
|
||||
)
|
||||
|
||||
if self.filters.get("item_code"):
|
||||
item = item.where(item_table.item_code == self.filters.get("item_code"))
|
||||
item = self._apply_filter(item, item_table, "item_code")
|
||||
|
||||
if self.filters.get("brand"):
|
||||
item = item.where(item_table.brand == self.filters.get("brand"))
|
||||
|
||||
return item
|
||||
|
||||
def _apply_filter(self, query, table, fieldname: str):
|
||||
filter_value = self.filters.get(fieldname)
|
||||
if not filter_value:
|
||||
return query
|
||||
|
||||
if isinstance(filter_value, list | tuple | set):
|
||||
return query.where(table[fieldname].isin(filter_value))
|
||||
|
||||
return query.where(table[fieldname] == filter_value)
|
||||
|
||||
def _get_warehouse_conditions(self, sle, sle_query) -> str:
|
||||
warehouse = frappe.qb.DocType("Warehouse")
|
||||
lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"])
|
||||
|
||||
warehouse_results = (
|
||||
frappe.qb.from_(warehouse)
|
||||
.select("name")
|
||||
.where((warehouse.lft >= lft) & (warehouse.rgt <= rgt))
|
||||
.run()
|
||||
)
|
||||
warehouse_results = [x[0] for x in warehouse_results]
|
||||
|
||||
return sle_query.where(sle.warehouse.isin(warehouse_results))
|
||||
return apply_warehouse_filter(sle_query, sle, self.filters)
|
||||
|
||||
def prepare_stock_reco_voucher_wise_count(self):
|
||||
self.stock_reco_voucher_wise_count = frappe._dict()
|
||||
|
||||
@@ -129,6 +129,18 @@ class TestStockAgeing(FrappeTestCase):
|
||||
self.assertEqual(queue[0][0], 10.0)
|
||||
self.assertEqual(queue[1][0], 10.0)
|
||||
|
||||
def test_item_filter_supports_multi_select_values(self):
|
||||
bundle = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
query = frappe.qb.from_(bundle).select(bundle.name)
|
||||
|
||||
filtered_query = FIFOSlots(frappe._dict(item_code=["Item A"]), [])._apply_filter(
|
||||
query, bundle, "item_code"
|
||||
)
|
||||
|
||||
sql = filtered_query.get_sql()
|
||||
self.assertIn(" IN ", sql)
|
||||
self.assertNotIn("=[", sql)
|
||||
|
||||
def test_basic_stock_reconciliation(self):
|
||||
"""
|
||||
Ledger (same wh): [+30, reco reset >> 50, -10]
|
||||
|
||||
@@ -109,10 +109,11 @@ def execute(filters=None):
|
||||
if sle.serial_no:
|
||||
update_available_serial_nos(available_serial_nos, sle)
|
||||
|
||||
if sle.actual_qty:
|
||||
if sle.actual_qty < 0:
|
||||
sle["in_out_rate"] = flt(sle.stock_value_difference / sle.actual_qty, precision)
|
||||
sle["incoming_rate"] = 0
|
||||
|
||||
elif sle.voucher_type == "Stock Reconciliation":
|
||||
elif sle.voucher_type == "Stock Reconciliation" and sle.actual_qty < 0:
|
||||
sle["in_out_rate"] = sle.valuation_rate
|
||||
|
||||
if (
|
||||
@@ -193,7 +194,7 @@ def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filte
|
||||
new_sle.update(row)
|
||||
new_sle.update(
|
||||
{
|
||||
"in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty else 0,
|
||||
"in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty < 0 else 0,
|
||||
"in_qty": row.qty if row.qty > 0 else 0,
|
||||
"out_qty": row.qty if row.qty < 0 else 0,
|
||||
"qty_after_transaction": qty_before_transaction + row.qty,
|
||||
@@ -375,7 +376,7 @@ def get_columns(filters):
|
||||
"convertible": "rate",
|
||||
},
|
||||
{
|
||||
"label": _("Valuation Rate"),
|
||||
"label": _("Outgoing Rate"),
|
||||
"fieldname": "in_out_rate",
|
||||
"fieldtype": filters.valuation_field_type,
|
||||
"width": 140,
|
||||
|
||||
@@ -887,7 +887,7 @@ class update_entries_after:
|
||||
# Only run in reposting
|
||||
self.get_serialized_values(sle)
|
||||
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and has_correct_data(sle):
|
||||
self.wh_data.qty_after_transaction = sle.qty_after_transaction
|
||||
|
||||
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
|
||||
@@ -2517,3 +2517,28 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj):
|
||||
@frappe.request_cache
|
||||
def is_repack_entry(stock_entry_id):
|
||||
return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack"
|
||||
|
||||
|
||||
def has_correct_data(sle):
|
||||
previous_sle = get_previous_sle(
|
||||
{
|
||||
"item_code": sle.item_code,
|
||||
"warehouse": sle.warehouse,
|
||||
"posting_date": sle.posting_date,
|
||||
"posting_time": sle.posting_time,
|
||||
"creation": sle.creation,
|
||||
"sle": sle.name,
|
||||
}
|
||||
)
|
||||
|
||||
if not previous_sle:
|
||||
return True
|
||||
|
||||
previous_qty = previous_sle.get("qty_after_transaction") or 0
|
||||
if previous_qty and not frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": sle.voucher_detail_no, "is_cancelled": 0, "actual_qty": ("<", 0)},
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -218,11 +218,13 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord
|
||||
@frappe.whitelist()
|
||||
def set_multiple_status(names, status):
|
||||
for name in json.loads(names):
|
||||
frappe.db.set_value("Issue", name, "status", status)
|
||||
set_status(name, status)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_status(name, status):
|
||||
frappe.has_permission("Issue", "write", name, throw=True)
|
||||
|
||||
frappe.db.set_value("Issue", name, "status", status)
|
||||
|
||||
|
||||
|
||||
@@ -64,21 +64,6 @@ def get_tasks(project, start=0, search=None, item_status=None):
|
||||
return list(filter(lambda x: not x.parent_task, tasks))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_task_html(project, start=0, item_status=None):
|
||||
return frappe.render_template(
|
||||
"erpnext/templates/includes/projects/project_tasks.html",
|
||||
{
|
||||
"doc": {
|
||||
"name": project,
|
||||
"project_name": project,
|
||||
"tasks": get_tasks(project, start, item_status=item_status),
|
||||
}
|
||||
},
|
||||
is_path=True,
|
||||
)
|
||||
|
||||
|
||||
def get_timesheets(project, start=0, search=None):
|
||||
filters = {"project": project}
|
||||
if search:
|
||||
@@ -104,15 +89,6 @@ def get_timesheets(project, start=0, search=None):
|
||||
return timesheets
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_timesheet_html(project, start=0):
|
||||
return frappe.render_template(
|
||||
"erpnext/templates/includes/projects/project_timesheets.html",
|
||||
{"doc": {"timesheets": get_timesheets(project, start)}},
|
||||
is_path=True,
|
||||
)
|
||||
|
||||
|
||||
def get_attachments(project):
|
||||
return frappe.get_all(
|
||||
"File",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "templates/web.html" %}
|
||||
{% block title %} {{ doc.name }} {% endblock %}
|
||||
{% block title %} {{ doc.name|e }} {% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<div class="page-breadcrumbs" data-html-block="breadcrumbs">
|
||||
<div class="page-breadcrumbs container" data-html-block="breadcrumbs">
|
||||
<ul class="breadcrumb">
|
||||
<li>
|
||||
<span class="fa fa-angle-left"></span>
|
||||
<a href="/projects?project={{ doc.project }}">{{ doc.project }}</a>
|
||||
<a href="/projects?project={{ doc.project|e }}">{{ doc.project|e }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -13,16 +13,7 @@
|
||||
{% block page_content %}
|
||||
<div class="row">
|
||||
<div class=" col-sm-8 ">
|
||||
<h1> {{ doc.subject }} </h1>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="page-header-actions-block" data-html-block="header-actions">
|
||||
<button type="submit" class="btn btn-primary btn-sm btn-form-submit">
|
||||
{{ __("Update") }}</button>
|
||||
<a href="tasks" class="btn btn-light btn-sm">
|
||||
{{ __("Cancel") }}</a>
|
||||
</div>
|
||||
<h1> {{ doc.subject|e }} </h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,50 +22,44 @@
|
||||
|
||||
<input type="hidden" name="web_form" value="tasks">
|
||||
<input type="hidden" name="doctype" value="Task">
|
||||
<input type="hidden" name="name" value="TASK00056">
|
||||
<input type="hidden" name="name" value="{{ doc.name|e }}">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12" style="max-width: 500px;">
|
||||
<div class="form-group">
|
||||
<label for="project" class="control-label text-muted small">{{ __("Project") }}</label>
|
||||
<input type="text" class="form-control" name="project" readonly value= "{{ doc.project }}">
|
||||
<label for="project" class="control-label text-muted small">{{ _("Project") }}</label>
|
||||
<input type="text" class="form-control" name="project" readonly value= "{{ doc.project or ""|e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject" class="control-label text-muted small">{{ __("Subject") }}</label>
|
||||
<input type="text" class="form-control" name="subject" readonly value="{{ doc.subject }}">
|
||||
<label for="subject" class="control-label text-muted small">{{ _("Subject") }}</label>
|
||||
<input type="text" class="form-control" name="subject" readonly value="{{ doc.subject or ""|e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="control-label text-muted small">{{ __("Details") }}</label>
|
||||
<textarea class="form-control" style="height: 200px;" name="description">{{ doc.description }}</textarea>
|
||||
<label for="description" class="control-label text-muted small">{{ _("Details") }}</label>
|
||||
<textarea class="form-control" style="height: 200px;" name="description" readonly>{{ doc.description or ""|e }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="priority" class="control-label text-muted small">{{ __("Priority") }}</label>
|
||||
<input type="text" class="form-control" name="priority" readonly value="{{ doc.priority }}">
|
||||
<label for="priority" class="control-label text-muted small">{{ _("Priority") }}</label>
|
||||
<input type="text" class="form-control" name="priority" readonly value="{{ doc.priority or ""|e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exp_start_date" class="control-label text-muted small">{{ __("Expected Start Date") }}</label>
|
||||
<input type="text" class="form-control hasDatepicker" name="exp_start_date" readonly value="{{ doc.exp_start_date }}">
|
||||
<label for="exp_start_date" class="control-label text-muted small">{{ _("Expected Start Date") }}</label>
|
||||
<input type="text" class="form-control hasDatepicker" name="exp_start_date" readonly value="{{ doc.exp_start_date or ""|e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exp_end_date" class="control-label text-muted small">{{ __("Expected End Date") }}</label>
|
||||
<input type="text" class="form-control hasDatepicker" name="exp_end_date" readonly value="{{ doc.exp_end_date }}">
|
||||
<label for="exp_end_date" class="control-label text-muted small">{{ _("Expected End Date") }}</label>
|
||||
<input type="text" class="form-control hasDatepicker" name="exp_end_date" readonly value="{{ doc.exp_end_date or ""|e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status" class="control-label text-muted small">{{ __("Status") }}</label>
|
||||
<select class="form-control" name="status" id="status" data-label="Status" data-fieldtype="Select">
|
||||
<option value="Open" selected="selected">
|
||||
{{ __("Open") }}</option><option value="Working">
|
||||
{{ __("Working") }}</option><option value="Pending Review">
|
||||
{{ __("Pending Review") }}</option><option value="Overdue">
|
||||
{{ __("Overdue") }}</option><option value="Closed">
|
||||
{{ __("Closed") }}</option><option value="Cancelled">
|
||||
{{ __("Cancelled") }}</option>
|
||||
<label for="status" class="control-label text-muted small">{{ _("Status") }}</label>
|
||||
<select class="form-control" name="status" id="status" data-label="Status" data-fieldtype="Select" disabled>
|
||||
<option value="{{ doc.status|e }}" selected="selected">{{ doc.status|e }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,68 +68,13 @@
|
||||
</div>
|
||||
|
||||
<div class="comments">
|
||||
<h3>{{ __("Comments") }}</h3>
|
||||
<h3>{{ _("Comments") }}</h3>
|
||||
<div class="no-comment">
|
||||
{% for comment in comments %}
|
||||
<p class="text-muted">{{comment.sender_full_name}}:
|
||||
{{comment.subject}} {{ __("on") }} {{comment.creation.strftime('%Y-%m-%d')}}</p>
|
||||
<p class="text-muted">{{comment.comment_email}}:
|
||||
{{comment.content|e}} {{ _("on") }} {{comment.creation.strftime('%Y-%m-%d')}}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="comment-form-wrapper">
|
||||
<a class="add-comment btn btn-light btn-sm">{{ __("Add Comment") }}</a>
|
||||
<div style="display: none;" id="comment-form">
|
||||
<p>{{ __("Add Comment") }}</p>
|
||||
<form>
|
||||
<fieldset>
|
||||
<textarea class="form-control" name="comment" rows="5" placeholder="Comment"></textarea>
|
||||
<p>
|
||||
<button class="btn btn-primary btn-sm" id="submit-comment">{{ __("Submit") }}</button>
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
frappe.ready(function() {
|
||||
var n_comments = $(".comment-row").length;
|
||||
$(".add-comment").click(function() {
|
||||
$(this).toggle(false);
|
||||
$("#comment-form").toggle();
|
||||
$("#comment-form textarea").val("");
|
||||
})
|
||||
$("#submit-comment").click(function() {
|
||||
var args = {
|
||||
comment_by_fullname: "test",
|
||||
comment_by: "admin@localhost.com",
|
||||
comment: $("[name='comment']").val(),
|
||||
reference_doctype: "Task",
|
||||
reference_name: "TASK00069",
|
||||
comment_type: "Comment",
|
||||
route: "tasks",
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
btn: this,
|
||||
type: "POST",
|
||||
method: "frappe.templates.includes.comments.comments.add_comment",
|
||||
args: args,
|
||||
callback: function(r) {
|
||||
if(r.exc) {
|
||||
if(r._server_messages)
|
||||
frappe.msgprint(r._server_messages);
|
||||
} else {
|
||||
$(r.message).appendTo("#comment-list");
|
||||
$(".no-comment, .add-comment").toggle(false);
|
||||
$("#comment-form")
|
||||
.replaceWith('<div class="text-muted">Thank you for your comment!</div>')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return false;
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,11 +5,12 @@ def get_context(context):
|
||||
context.no_cache = 1
|
||||
|
||||
task = frappe.get_doc("Task", frappe.form_dict.task)
|
||||
task.check_permission()
|
||||
|
||||
context.comments = frappe.get_all(
|
||||
"Communication",
|
||||
filters={"reference_name": task.name, "comment_type": "comment"},
|
||||
fields=["subject", "sender_full_name", "communication_date"],
|
||||
"Comment",
|
||||
filters={"reference_doctype": "Task", "reference_name": task.name, "comment_type": "Comment"},
|
||||
fields=["content", "comment_email", "creation"],
|
||||
)
|
||||
|
||||
context.doc = task
|
||||
|
||||
@@ -12,10 +12,13 @@ no_cache = 1
|
||||
|
||||
|
||||
def get_context(context):
|
||||
is_enabled = frappe.db.get_single_value("Appointment Booking Settings", "enable_scheduling")
|
||||
if is_enabled:
|
||||
return context
|
||||
else:
|
||||
handle_appointment_booking_disabled()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def handle_appointment_booking_disabled():
|
||||
if not frappe.get_single_value("Appointment Booking Settings", "enable_scheduling"):
|
||||
frappe.redirect_to_message(
|
||||
_("Appointment Scheduling Disabled"),
|
||||
_("Appointment Scheduling has been disabled for this site"),
|
||||
@@ -27,9 +30,9 @@ def get_context(context):
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_appointment_settings():
|
||||
settings = frappe.get_cached_value(
|
||||
handle_appointment_booking_disabled()
|
||||
settings = frappe.get_single_value(
|
||||
"Appointment Booking Settings",
|
||||
None,
|
||||
["advance_booking_days", "appointment_duration", "success_redirect_url"],
|
||||
as_dict=True,
|
||||
)
|
||||
@@ -38,6 +41,7 @@ def get_appointment_settings():
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_timezones():
|
||||
handle_appointment_booking_disabled()
|
||||
import pytz
|
||||
|
||||
return pytz.all_timezones
|
||||
@@ -46,6 +50,7 @@ def get_timezones():
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_appointment_slots(date, timezone):
|
||||
# Convert query to local timezones
|
||||
handle_appointment_booking_disabled()
|
||||
format_string = "%Y-%m-%d %H:%M:%S"
|
||||
query_start_time = datetime.datetime.strptime(date + " 00:00:00", format_string)
|
||||
query_end_time = datetime.datetime.strptime(date + " 23:59:59", format_string)
|
||||
@@ -54,7 +59,11 @@ def get_appointment_slots(date, timezone):
|
||||
now = convert_to_guest_timezone(timezone, datetime.datetime.now())
|
||||
|
||||
# Database queries
|
||||
settings = frappe.get_doc("Appointment Booking Settings")
|
||||
settings = frappe.get_single_value(
|
||||
"Appointment Booking Settings",
|
||||
["holiday_list", "appointment_duration", "number_of_agents", "availability_of_slots"],
|
||||
as_dict=True,
|
||||
)
|
||||
holiday_list = frappe.get_doc("Holiday List", settings.holiday_list)
|
||||
timeslots = get_available_slots_between(query_start_time, query_end_time, settings)
|
||||
|
||||
@@ -95,6 +104,7 @@ def get_available_slots_between(query_start_time, query_end_time, settings):
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def create_appointment(date, time, tz, contact):
|
||||
handle_appointment_booking_disabled()
|
||||
format_string = "%Y-%m-%d %H:%M:%S"
|
||||
scheduled_time = datetime.datetime.strptime(date + " " + time, format_string)
|
||||
# Strip tzinfo from datetime objects since it's handled by the doctype
|
||||
|
||||
Reference in New Issue
Block a user