diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 21dd3d48794..ffaa6b5e7df 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -57,7 +57,7 @@ jobs: run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} @@ -66,7 +66,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -81,7 +81,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index 720f0e0ed35..8f2dfe0c0e8 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -76,7 +76,7 @@ jobs: run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} @@ -85,7 +85,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -100,7 +100,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index a6887066570..2470438aa7c 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -66,7 +66,7 @@ jobs: run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} @@ -75,7 +75,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -90,7 +90,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/ch_240812_schulkontenrahmen_veb_de.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/ch_240812_schulkontenrahmen_veb_de.json new file mode 100644 index 00000000000..dff27a213c1 --- /dev/null +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/ch_240812_schulkontenrahmen_veb_de.json @@ -0,0 +1,532 @@ +{ + "country_code": "ch", + "name": "240812 Schulkontenrahmen VEB - DE", + "tree": { + "Aktiven": { + "account_number": "1", + "is_group": 1, + "root_type": "Asset", + "Umlaufvermögen": { + "account_number": "10", + "is_group": 1, + "Flüssige Mittel": { + "account_number": "100", + "is_group": 1, + "Kasse": { + "account_number": "1000", + "account_type": "Cash" + }, + "Bankguthaben": { + "account_number": "1020", + "account_type": "Bank" + } + }, + "Kurzfristig gehaltene Aktiven mit Börsenkurs": { + "account_number": "106", + "is_group": 1, + "Wertschriften": { + "account_number": "1060" + }, + "Wertberichtigungen Wertschriften": { + "account_number": "1069" + } + }, + "Forderungen aus Lieferungen und Leistungen": { + "account_number": "110", + "is_group": 1, + "Forderungen aus Lieferungen und Leistungen (Debitoren)": { + "account_number": "1100" + }, + "Delkredere": { + "account_number": "1109" + } + }, + "Übrige kurzfristige Forderungen": { + "account_number": "114", + "is_group": 1, + "Vorschüsse und Darlehen": { + "account_number": "1140" + }, + "Wertberichtigungen Vorschüsse und Darlehen": { + "account_number": "1149" + }, + "Vorsteuer MWST Material, Waren, Dienstleistungen, Energie": { + "account_number": "1170" + }, + "Vorsteuer MWST Investitionen, übriger Betriebsaufwand": { + "account_number": "1171" + }, + "Verrechnungssteuer": { + "account_number": "1176" + }, + "Forderungen gegenüber Sozialversicherungen und Vorsorgeeinrichtungen": { + "account_number": "1180" + }, + "Quellensteuer": { + "account_number": "1189" + }, + "Sonstige kurzfristige Forderungen": { + "account_number": "1190" + }, + "Wertberichtigungen sonstige kurzfristige Forderungen": { + "account_number": "1199" + } + }, + "Vorräte und nicht fakturierte Dienstleistungen": { + "account_number": "120", + "is_group": 1, + "Handelswaren": { + "account_number": "1200" + }, + "Rohstoffe": { + "account_number": "1210" + }, + "Werkstoffe": { + "account_number": "1220" + }, + "Hilfs- und Verbrauchsmaterial": { + "account_number": "1230" + }, + "Handelswaren in Konsignation": { + "account_number": "1250" + }, + "Fertige Erzeugnisse": { + "account_number": "1260" + }, + "Unfertige Erzeugnisse": { + "account_number": "1270" + }, + "Nicht fakturierte Dienstleistungen": { + "account_number": "1280" + } + }, + "Aktive Rechnungsabgrenzungen": { + "account_number": "130", + "is_group": 1, + "Bezahlter Aufwand des Folgejahres": { + "account_number": "1300" + }, + "Noch nicht erhaltener Ertrag": { + "account_number": "1301" + } + } + }, + "Anlagevermögen": { + "account_number": "14", + "is_group": 1, + "Finanzanlagen": { + "account_number": "140", + "is_group": 1, + "Wertschriften": { + "account_number": "1400" + }, + "Wertberichtigungen Wertschriften": { + "account_number": "1409" + }, + "Darlehen": { + "account_number": "1440" + }, + "Hypotheken": { + "account_number": "1441" + }, + "Wertberichtigungen langfristige Forderungen": { + "account_number": "1449" + } + }, + "Beteiligungen": { + "account_number": "148", + "is_group": 1, + "Beteiligungen": { + "account_number": "1480" + }, + "Wertberichtigungen Beteiligungen": { + "account_number": "1489" + } + }, + "Mobile Sachanlagen": { + "account_number": "150", + "is_group": 1, + "Maschinen und Apparate": { + "account_number": "1500" + }, + "Wertberichtigungen Maschinen und Apparate": { + "account_number": "1509" + }, + "Mobiliar und Einrichtungen": { + "account_number": "1510" + }, + "Wertberichtigungen Mobiliar und Einrichtungen": { + "account_number": "1519" + }, + "Büromaschinen, Informatik, Kommunikationstechnologie": { + "account_number": "1520" + }, + "Wertberichtigungen Büromaschinen, Informatik, Kommunikationstechnologie": { + "account_number": "1529" + }, + "Fahrzeuge": { + "account_number": "1530" + }, + "Wertberichtigungen Fahrzeuge": { + "account_number": "1539" + }, + "Werkzeuge und Geräte": { + "account_number": "1540" + }, + "Wertberichtigungen Werkzeuge und Geräte": { + "account_number": "1549" + } + }, + "Immobile Sachanlagen": { + "account_number": "160", + "is_group": 1, + "Geschäftsliegenschaften": { + "account_number": "1600" + }, + "Wertberichtigungen Geschäftsliegenschaften": { + "account_number": "1609" + } + }, + "Immaterielle Werte": { + "account_number": "170", + "is_group": 1, + "Patente, Know-how, Lizenzen, Rechte, Entwicklungen": { + "account_number": "1700" + }, + "Wertberichtigungen Patente, Know-how, Lizenzen, Rechte, Entwicklungen": { + "account_number": "1709" + }, + "Goodwill": { + "account_number": "1770" + }, + "Wertberichtigungen Goodwill": { + "account_number": "1779" + } + }, + "Nicht einbezahltes Grund-, Gesellschafter- oder Stiftungskapital": { + "account_number": "180", + "is_group": 1, + "Nicht einbezahltes Aktien-, Stamm-, Anteilschein- oder Stiftungskapital": { + "account_number": "1850" + } + } + } + }, + "Passiven": { + "account_number": "2", + "is_group": 1, + "root_type": "Liability", + "Kurzfristiges Fremdkapital": { + "account_number": "20", + "is_group": 1, + "Verbindlichkeiten aus Lieferungen und Leistungen": { + "account_number": "200", + "is_group": 1, + "Verbindlichkeiten aus Lieferungen und Leistungen (Kreditoren)": { + "account_number": "2000" + }, + "Erhaltene Anzahlungen": { + "account_number": "2030" + } + }, + "Kurzfristige verzinsliche Verbindlichkeiten": { + "account_number": "210", + "is_group": 1, + "Bankverbindlichkeiten": { + "account_number": "2100" + }, + "Verbindlichkeiten aus Finanzierungsleasing": { + "account_number": "2120" + }, + "Übrige verzinsliche Verbindlichkeiten": { + "account_number": "2140" + } + }, + "Übrige kurzfristige Verbindlichkeiten": { + "account_number": "220", + "is_group": 1, + "Geschuldete MWST (Umsatzsteuer)": { + "account_number": "2200" + }, + "Abrechnungskonto MWST": { + "account_number": "2201" + }, + "Verrechnungssteuer": { + "account_number": "2206" + }, + "Direkte Steuern": { + "account_number": "2208" + }, + "Sonstige kurzfristige Verbindlichkeiten": { + "account_number": "2210" + }, + "Beschlossene Ausschüttungen": { + "account_number": "2261" + }, + "Sozialversicherungen und Vorsorgeeinrichtungen": { + "account_number": "2270" + }, + "Quellensteuer": { + "account_number": "2279" + } + }, + "Passive Rechnungsabgrenzungen und kurzfristige Rückstellungen": { + "account_number": "230", + "is_group": 1, + "Noch nicht bezahlter Aufwand": { + "account_number": "2300" + }, + "Erhaltener Ertrag des Folgejahres": { + "account_number": "2301" + }, + "Kurzfristige Rückstellungen": { + "account_number": "2330" + } + } + }, + "Langfristiges Fremdkapital": { + "account_number": "24", + "is_group": 1, + "Langfristige verzinsliche Verbindlichkeiten": { + "account_number": "240", + "is_group": 1, + "Bankverbindlichkeiten": { + "account_number": "2400" + }, + "Verbindlichkeiten aus Finanzierungsleasing": { + "account_number": "2420" + }, + "Obligationenanleihen": { + "account_number": "2430" + }, + "Darlehen": { + "account_number": "2450" + }, + "Hypotheken": { + "account_number": "2451" + } + }, + "Übrige langfristige Verbindlichkeiten": { + "account_number": "250", + "is_group": 1, + "Übrige langfristige Verbindlichkeiten (unverzinslich)": { + "account_number": "2500" + } + }, + "Rückstellungen sowie vom Gesetz vorgesehene ähnliche Positionen": { + "account_number": "260", + "is_group": 1, + "Rückstellungen": { + "account_number": "2600" + } + } + }, + "Eigenkapital (juristische Personen)": { + "account_number": "28", + "is_group": 1, + "Grund-, Gesellschafter- oder Stiftungskapital": { + "account_number": "280", + "is_group": 1, + "Aktien-, Stamm-, Anteilschein- oder Stiftungskapital": { + "account_number": "2800" + } + }, + "Reserven und Jahresgewinn oder Jahresverlust": { + "account_number": "290", + "is_group": 1, + "Gesetzliche Kapitalreserve": { + "account_number": "2900" + }, + "Reserve für eigene Kapitalanteile": { + "account_number": "2930" + }, + "Aufwertungsreserve": { + "account_number": "2940" + }, + "Gesetzliche Gewinnreserve": { + "account_number": "2950" + }, + "Freiwillige Gewinnreserven": { + "account_number": "2960" + }, + "Gewinnvortrag oder Verlustvortrag": { + "account_number": "2970" + }, + "Jahresgewinn oder Jahresverlust": { + "account_number": "2979" + }, + "Eigene Aktien, Stammanteile oder Anteilscheine (Minusposten)": { + "account_number": "2980" + } + } + } + }, + "Betrieblicher Ertrag aus Lieferungen und Leistungen": { + "account_number": "3", + "is_group": 1, + "root_type": "Income", + "Produktionserlöse": { + "account_number": "3000" + }, + "Handelserlöse": { + "account_number": "3200" + }, + "Dienstleistungserlöse": { + "account_number": "3400" + }, + "Übrige Erlöse aus Lieferungen und Leistungen": { + "account_number": "3600" + }, + "Eigenleistungen": { + "account_number": "3700" + }, + "Eigenverbrauch": { + "account_number": "3710" + }, + "Erlösminderungen": { + "account_number": "3800" + }, + "Verluste Forderungen (Debitoren), Veränderung Delkredere": { + "account_number": "3805" + }, + "Bestandesänderungen unfertige Erzeugnisse": { + "account_number": "3900" + }, + "Bestandesänderungen fertige Erzeugnisse": { + "account_number": "3901" + }, + "Bestandesänderungen nicht fakturierte Dienstleistungen": { + "account_number": "3940" + } + }, + "Aufwand für Material, Handelswaren, Dienstleistungen und Energie": { + "account_number": "4", + "is_group": 1, + "root_type": "Expense", + "Materialaufwand Produktion": { + "account_number": "4000" + }, + "Handelswarenaufwand": { + "account_number": "4200" + }, + "Aufwand für bezogene Dienstleistungen": { + "account_number": "4400" + }, + "Energieaufwand zur Leistungserstellung": { + "account_number": "4500" + }, + "Aufwandminderungen": { + "account_number": "4900" + } + }, + "Personalaufwand": { + "account_number": "5", + "is_group": 1, + "root_type": "Expense", + "Lohnaufwand": { + "account_number": "5000" + }, + "Sozialversicherungsaufwand": { + "account_number": "5700" + }, + "Übriger Personalaufwand": { + "account_number": "5800" + }, + "Leistungen Dritter": { + "account_number": "5900" + } + }, + "Übriger betrieblicher Aufwand, Abschreibungen und Wertberichtigungen sowie Finanzergebnis": { + "account_number": "6", + "is_group": 1, + "root_type": "Expense", + "Raumaufwand": { + "account_number": "6000" + }, + "Unterhalt, Reparaturen, Ersatz mobile Sachanlagen": { + "account_number": "6100" + }, + "Leasingaufwand mobile Sachanlagen": { + "account_number": "6105" + }, + "Fahrzeug- und Transportaufwand": { + "account_number": "6200" + }, + "Fahrzeugleasing und -mieten": { + "account_number": "6260" + }, + "Sachversicherungen, Abgaben, Gebühren, Bewilligungen": { + "account_number": "6300" + }, + "Energie- und Entsorgungsaufwand": { + "account_number": "6400" + }, + "Verwaltungsaufwand": { + "account_number": "6500" + }, + "Informatikaufwand inkl. Leasing": { + "account_number": "6570" + }, + "Werbeaufwand": { + "account_number": "6600" + }, + "Sonstiger betrieblicher Aufwand": { + "account_number": "6700" + }, + "Abschreibungen und Wertberichtigungen auf Positionen des Anlagevermögens": { + "account_number": "6800" + }, + "Finanzaufwand": { + "account_number": "6900" + }, + "Finanzertrag": { + "account_number": "6950" + } + }, + "Betrieblicher Nebenerfolg": { + "account_number": "7", + "is_group": 1, + "root_type": "Income", + "Ertrag Nebenbetrieb": { + "account_number": "7000" + }, + "Aufwand Nebenbetrieb": { + "account_number": "7010" + }, + "Ertrag betriebliche Liegenschaft": { + "account_number": "7500" + }, + "Aufwand betriebliche Liegenschaft": { + "account_number": "7510" + } + }, + "Betriebsfremder, ausserordentlicher, einmaliger oder periodenfremder Aufwand und Ertrag": { + "account_number": "8", + "is_group": 1, + "root_type": "Expense", + "Betriebsfremder Aufwand": { + "account_number": "8000" + }, + "Betriebsfremder Ertrag": { + "account_number": "8100" + }, + "Ausserordentlicher, einmaliger oder periodenfremder Aufwand": { + "account_number": "8500" + }, + "Ausserordentlicher, einmaliger oder periodenfremder Ertrag": { + "account_number": "8510" + }, + "Direkte Steuern": { + "account_number": "8900" + } + }, + "Abschluss": { + "account_number": "9", + "is_group": 1, + "root_type": "Equity", + "Jahresgewinn oder Jahresverlust": { + "account_number": "9200" + } + } + } +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json index 5858f10bb0b..f05d20a0a49 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json @@ -31,7 +31,8 @@ "label": "Reference Document Type", "options": "DocType", "read_only_depends_on": "eval:!doc.__islocal", - "reqd": 1 + "reqd": 1, + "search_index": 1 }, { "default": "0", diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 62a4c74a933..9de1b4216ce 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -802,7 +802,6 @@ def get_je_matching_query( .where(je.clearance_date.isnull()) .where(jea.account == common_filters.bank_account) .where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0) - .where(je.docstatus == 1) .where(filter_by_date) .orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date) ) diff --git a/erpnext/accounts/doctype/bank_transaction/auto_match_party.py b/erpnext/accounts/doctype/bank_transaction/auto_match_party.py index a1271b9c32a..66aab9d62dd 100644 --- a/erpnext/accounts/doctype/bank_transaction/auto_match_party.py +++ b/erpnext/accounts/doctype/bank_transaction/auto_match_party.py @@ -45,45 +45,41 @@ class AutoMatchbyAccountIBAN: if not (self.bank_party_account_number or self.bank_party_iban): return None - result = self.match_account_in_party() - return result + return self.match_account_in_party() def match_account_in_party(self) -> tuple | None: - """Check if there is a IBAN/Account No. match in Customer/Supplier/Employee""" - result = None - parties = get_parties_in_order(self.deposit) - or_filters = self.get_or_filters() + """ + Returns (Party Type, Party) if a matching account is found in Bank Account or Employee: + 1. Get party from a matching (iban/account no) Bank Account + 2. If not found, get party from Employee with matching bank account details (iban/account no) + """ + if not (self.bank_party_account_number or self.bank_party_iban): + # Nothing to match + return None - for party in parties: - party_result = frappe.db.get_all( - "Bank Account", or_filters=or_filters, pluck="party", limit_page_length=1 - ) + # Search for a matching Bank Account that has party set + party_result = frappe.db.get_all( + "Bank Account", + or_filters=self.get_or_filters(), + filters={"party_type": ("is", "set"), "party": ("is", "set")}, + fields=["party", "party_type"], + limit_page_length=1, + ) + if result := party_result[0] if party_result else None: + return (result["party_type"], result["party"]) - if party == "Employee" and not party_result: - # Search in Bank Accounts first for Employee, and then Employee record - if "bank_account_no" in or_filters: - or_filters["bank_ac_no"] = or_filters.pop("bank_account_no") + # If no party is found, search in Employee (since it has bank account details) + if employee_result := frappe.db.get_all( + "Employee", or_filters=self.get_or_filters("Employee"), pluck="name", limit_page_length=1 + ): + return ("Employee", employee_result[0]) - party_result = frappe.db.get_all( - party, or_filters=or_filters, pluck="name", limit_page_length=1 - ) - - if "bank_ac_no" in or_filters: - or_filters["bank_account_no"] = or_filters.pop("bank_ac_no") - - if party_result: - result = ( - party, - party_result[0], - ) - break - - return result - - def get_or_filters(self) -> dict: + def get_or_filters(self, party: str | None = None) -> dict: + """Return OR filters for Bank Account and IBAN""" or_filters = {} if self.bank_party_account_number: - or_filters["bank_account_no"] = self.bank_party_account_number + bank_ac_field = "bank_ac_no" if party == "Employee" else "bank_account_no" + or_filters[bank_ac_field] = self.bank_party_account_number if self.bank_party_iban: or_filters["iban"] = self.bank_party_iban @@ -103,8 +99,7 @@ class AutoMatchbyPartyNameDescription: if not (self.bank_party_name or self.description): return None - result = self.match_party_name_desc_in_party() - return result + return self.match_party_name_desc_in_party() def match_party_name_desc_in_party(self) -> tuple | None: """Fuzzy search party name and/or description against parties in the system""" @@ -113,7 +108,8 @@ class AutoMatchbyPartyNameDescription: for party in parties: filters = {"status": "Active"} if party == "Employee" else {"disabled": 0} - names = frappe.get_all(party, filters=filters, pluck=party.lower() + "_name") + field = f"{party.lower()}_name" + names = frappe.get_all(party, filters=filters, fields=[f"{field} as party_name", "name"]) for field in ["bank_party_name", "description"]: if not self.get(field): @@ -132,16 +128,14 @@ class AutoMatchbyPartyNameDescription: def fuzzy_search_and_return_result(self, party, names, field) -> tuple | None: skip = False - result = process.extract(query=self.get(field), choices=names, scorer=fuzz.token_set_ratio) + result = process.extract( + query=self.get(field), + choices={row.get("name"): row.get("party_name") for row in names}, + scorer=fuzz.token_set_ratio, + ) party_name, skip = self.process_fuzzy_result(result) - if not party_name: - return None, skip - - return ( - party, - party_name, - ), skip + return ((party, party_name), skip) if party_name else (None, skip) def process_fuzzy_result(self, result: list | None): """ @@ -150,30 +144,30 @@ class AutoMatchbyPartyNameDescription: Returns: Result, Skip (whether or not to discontinue matching) """ - PARTY, SCORE, CUTOFF = 0, 1, 80 + SCORE, PARTY_ID, CUTOFF = 1, 2, 80 if not result or not len(result): return None, False first_result = result[0] if len(result) == 1: - return (first_result[PARTY] if first_result[SCORE] > CUTOFF else None), True + return (first_result[PARTY_ID] if first_result[SCORE] > CUTOFF else None), True - second_result = result[1] if first_result[SCORE] > CUTOFF: + second_result = result[1] # If multiple matches with the same score, return None but discontinue matching # Matches were found but were too close to distinguish between if first_result[SCORE] == second_result[SCORE]: return None, True - return first_result[PARTY], True + return first_result[PARTY_ID], True else: return None, False def get_parties_in_order(deposit: float) -> list: - parties = ["Supplier", "Employee", "Customer"] # most -> least likely to receive - if flt(deposit) > 0: - parties = ["Customer", "Supplier", "Employee"] # most -> least likely to pay - - return parties + return ( + ["Customer", "Supplier", "Employee"] # most -> least likely to pay us + if flt(deposit) > 0 + else ["Supplier", "Employee", "Customer"] # most -> least likely to receive from us + ) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index faa38763b80..a64aba417a6 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -430,12 +430,6 @@ frappe.ui.form.on("Journal Entry Account", { }); } }, - cost_center: function (frm, dt, dn) { - // Don't reset for Gain/Loss type journals, as it will make Debit and Credit values '0' - if (frm.doc.voucher_type != "Exchange Gain Or Loss") { - erpnext.journal_entry.set_account_details(frm, dt, dn); - } - }, account: function (frm, dt, dn) { erpnext.journal_entry.set_account_details(frm, dt, dn); diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index 8f4c4e3ccda..54aa3eaf96f 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -146,10 +146,9 @@ class TestJournalEntry(unittest.TestCase): "credit_in_account_currency": 0 if diff > 0 else abs(diff), }, ) - jv.insert() if account_bal == stock_bal: - self.assertRaises(StockAccountInvalidTransaction, jv.submit) + self.assertRaises(StockAccountInvalidTransaction, jv.save) frappe.db.rollback() else: jv.submit() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index d714df0927b..b0090f27004 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1576,6 +1576,14 @@ class PaymentEntry(AccountsController): elif self.payment_type in ("Pay", "Internal Transfer"): return self.paid_from + def get_value_in_transaction_currency(self, account_currency, gl_dict, field): + company_currency = erpnext.get_company_currency(self.company) + conversion_rate = self.target_exchange_rate + if self.paid_from_account_currency != company_currency: + conversion_rate = self.source_exchange_rate + + return flt(gl_dict.get(field, 0) / (conversion_rate or 1)) + def update_advance_paid(self): if self.payment_type in ("Receive", "Pay") and self.party: for d in self.get("references"): @@ -2826,7 +2834,7 @@ def get_payment_entry( if pe.party_type in ["Customer", "Supplier"]: bank_account = get_party_bank_account(pe.party_type, pe.party) - pe.set("bank_account", bank_account) + pe.set("party_bank_account", bank_account) pe.set_bank_account_data() # only Purchase Invoice can be blocked individually diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index eadb714baa3..ed940470d6c 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -83,8 +83,7 @@ class TestPaymentRequest(FrappeTestCase): def test_payment_entry_against_purchase_invoice(self): si_usd = make_purchase_invoice( - customer="_Test Supplier USD", - debit_to="_Test Payable USD - _TC", + supplier="_Test Supplier USD", currency="USD", conversion_rate=50, ) @@ -108,8 +107,7 @@ class TestPaymentRequest(FrappeTestCase): def test_multiple_payment_entry_against_purchase_invoice(self): purchase_invoice = make_purchase_invoice( - customer="_Test Supplier USD", - debit_to="_Test Payable USD - _TC", + supplier="_Test Supplier USD", currency="USD", conversion_rate=50, ) diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 994b6776e3c..22f2965b86e 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -23,6 +23,7 @@ "hide_unavailable_items", "auto_add_item_to_cart", "validate_stock_on_save", + "print_receipt_on_order_complete", "column_break_16", "update_stock", "ignore_pricing_rule", @@ -375,6 +376,12 @@ "fieldname": "disable_rounded_total", "fieldtype": "Check", "label": "Disable Rounded Total" + }, + { + "default": "0", + "fieldname": "print_receipt_on_order_complete", + "fieldtype": "Check", + "label": "Print Receipt on Order Complete" } ], "icon": "icon-cog", @@ -402,7 +409,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2022-08-10 12:57:06.241439", + "modified": "2025-01-01 11:07:03.161950", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 37197e1fb7a..ea27116e91c 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -47,6 +47,7 @@ class POSProfile(Document): letter_head: DF.Link | None payments: DF.Table[POSPaymentMethod] print_format: DF.Link | None + print_receipt_on_order_complete: DF.Check select_print_heading: DF.Link | None selling_price_list: DF.Link | None tax_category: DF.Link | None diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 385cc1a685e..73cb2483811 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -415,8 +415,6 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False): "parent": args.parent, "parenttype": args.parenttype, "child_docname": args.get("child_docname"), - "discount_percentage": 0.0, - "discount_amount": 0, } ) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index dc6ee6c1469..3ab214751f7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -10,7 +10,6 @@ from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date -from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, @@ -33,7 +32,7 @@ from erpnext.accounts.general_ledger import ( merge_similar_entries, ) from erpnext.accounts.party import get_due_date, get_party_account -from erpnext.accounts.utils import get_account_currency, get_fiscal_year +from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status @@ -838,12 +837,12 @@ class PurchaseInvoice(BuyingController): def update_supplier_outstanding(self, update_outstanding): if update_outstanding == "No": - update_outstanding_amt( - self.credit_to, - "Supplier", - self.supplier, - self.doctype, - self.return_against if cint(self.is_return) and self.return_against else self.name, + update_voucher_outstanding( + voucher_type=self.doctype, + 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, ) def get_gl_entries(self, warehouse_account=None): @@ -1126,6 +1125,7 @@ class PurchaseInvoice(BuyingController): exchange_rate_map[item.purchase_receipt] and self.conversion_rate != exchange_rate_map[item.purchase_receipt] and item.net_rate == net_rate_map[item.pr_detail] + and item.item_code in stock_items ): discrepancy_caused_by_exchange_rate_difference = ( item.qty * item.net_rate @@ -1796,13 +1796,13 @@ class PurchaseInvoice(BuyingController): self.remove(d) ## Add pending vouchers on which tax was withheld - for voucher_no, voucher_details in voucher_wise_amount.items(): + for row in voucher_wise_amount: self.append( "tax_withheld_vouchers", { - "voucher_name": voucher_no, - "voucher_type": voucher_details.get("voucher_type"), - "taxable_amount": voucher_details.get("amount"), + "voucher_name": row.voucher_name, + "voucher_type": row.voucher_type, + "taxable_amount": row.taxable_amount, }, ) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js index 031b2341bb6..6bfb48c13d2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js @@ -45,12 +45,16 @@ frappe.listview_settings["Purchase Invoice"] = { }, onload: function (listview) { - listview.page.add_action_item(__("Purchase Receipt"), () => { - erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Purchase Receipt"); - }); + if (frappe.model.can_create("Purchase Receipt")) { + listview.page.add_action_item(__("Purchase Receipt"), () => { + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Purchase Receipt"); + }); + } - listview.page.add_action_item(__("Payment"), () => { - erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment Entry"); - }); + if (frappe.model.can_create("Payment Entry")) { + listview.page.add_action_item(__("Payment"), () => { + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment Entry"); + }); + } }, }; diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 4d62c0d354d..bc28edbf396 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -372,6 +372,53 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self): + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + make_purchase_invoice as create_purchase_invoice, + ) + + # Creating Purchase Invoice with USD currency + pr = frappe.new_doc("Purchase Receipt") + pr.currency = "USD" + pr.company = "_Test Company with perpetual inventory" + pr.conversion_rate = (70,) + pr.supplier = "_Test Supplier USD" + pr.append( + "items", + { + "item_code": "_Test Non Stock Item", + "qty": 1, + "rate": 100, + }, + ) + pr.append( + "items", + {"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"}, + ) + pr.insert() + pr.submit() + + # Createing purchase invoice against Purchase Receipt + pi = create_purchase_invoice(pr.name) + pi.conversion_rate = 80 + pi.credit_to = "_Test Payable USD - TCP1" + pi.insert() + pi.submit() + + # Get exchnage gain and loss account + exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account") + + # fetching the latest GL Entry with exchange gain and loss account account + amount = frappe.db.get_value( + "GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit" + ) + + discrepancy_caused_by_exchange_rate_diff = abs( + pi.items[1].base_net_amount - pr.items[1].base_net_amount + ) + + self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + def test_purchase_invoice_change_naming_series(self): pi = frappe.copy_doc(test_records[1]) pi.insert() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index c07e2a392ae..da2e362cf27 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -16,6 +16,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( setup(doc) { this.setup_posting_date_time_check(); super.setup(doc); + this.frm.make_methods = { + Dunning: this.make_dunning.bind(this), + "Invoice Discounting": this.make_invoice_discounting.bind(this), + }; } company() { super.company(); @@ -121,12 +125,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( }, __("Create") ); - - cur_frm.add_custom_button( + this.frm.add_custom_button( __("Invoice Discounting"), - function () { - cur_frm.events.create_invoice_discounting(cur_frm); - }, + this.make_invoice_discounting.bind(this), __("Create") ); @@ -135,22 +136,14 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( .reduce((prev, current) => prev || current, false); if (payment_is_overdue) { - this.frm.add_custom_button( - __("Dunning"), - () => { - this.frm.events.create_dunning(this.frm); - }, - __("Create") - ); + this.frm.add_custom_button(__("Dunning"), this.make_dunning.bind(this), __("Create")); } } if (doc.docstatus === 1) { cur_frm.add_custom_button( __("Maintenance Schedule"), - function () { - cur_frm.cscript.make_maintenance_schedule(); - }, + this.make_maintenance_schedule.bind(this), __("Create") ); } @@ -185,6 +178,20 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm); } + make_invoice_discounting() { + frappe.model.open_mapped_doc({ + method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting", + frm: this.frm, + }); + } + + make_dunning() { + frappe.model.open_mapped_doc({ + method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning", + frm: this.frm, + }); + } + make_maintenance_schedule() { frappe.model.open_mapped_doc({ method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", @@ -1045,20 +1052,6 @@ frappe.ui.form.on("Sales Invoice", { frm.set_df_property("return_against", "label", __("Adjustment Against")); } }, - - create_invoice_discounting: function (frm) { - frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting", - frm: frm, - }); - }, - - create_dunning: function (frm) { - frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning", - frm: frm, - }); - }, }); frappe.ui.form.on("Sales Invoice Timesheet", { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 6e039b4b34f..5753eba8cc1 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -24,7 +24,11 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category ) 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.utils import cancel_exchange_gain_loss_journal, get_account_currency +from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + get_account_currency, + update_voucher_outstanding, +) from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, get_disposal_account_and_cost_center, @@ -1192,14 +1196,14 @@ class SalesInvoice(SellingController): make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) if update_outstanding == "No": - from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt - - update_outstanding_amt( - self.debit_to, - "Customer", - self.customer, - self.doctype, - self.return_against if cint(self.is_return) and self.return_against else self.name, + update_voucher_outstanding( + voucher_type=self.doctype, + voucher_no=self.return_against + if cint(self.is_return) and self.return_against + else self.name, + account=self.debit_to, + party_type="Customer", + party=self.customer, ) elif self.docstatus == 2 and cint(self.update_stock) and cint(auto_accounting_for_stock): diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js index 3371a63cca2..ea3ae2b6fab 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js @@ -32,12 +32,16 @@ frappe.listview_settings["Sales Invoice"] = { right_column: "grand_total", onload: function (listview) { - listview.page.add_action_item(__("Delivery Note"), () => { - erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Delivery Note"); - }); + if (frappe.model.can_create("Delivery Note")) { + listview.page.add_action_item(__("Delivery Note"), () => { + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Delivery Note"); + }); + } - listview.page.add_action_item(__("Payment"), () => { - erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment Entry"); - }); + if (frappe.model.can_create("Payment Entry")) { + listview.page.add_action_item(__("Payment"), () => { + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment Entry"); + }); + } }, }; diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 439fc5639e5..1c33246ee68 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -4235,6 +4235,7 @@ class TestSalesInvoice(FrappeTestCase): si = create_sales_invoice(do_not_submit=True) project = frappe.new_doc("Project") + project.company = "_Test Company" project.project_name = "Test Total Billed Amount" project.save() @@ -4245,6 +4246,30 @@ class TestSalesInvoice(FrappeTestCase): doc = frappe.get_doc("Project", project.name) self.assertEqual(doc.total_billed_amount, si.grand_total) + def test_pos_returns_with_party_account_currency(self): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + pos_profile = make_pos_profile() + pos_profile.payments = [] + pos_profile.append("payments", {"default": 1, "mode_of_payment": "Cash"}) + pos_profile.save() + + pos = create_sales_invoice( + customer="_Test Customer USD", + currency="USD", + conversion_rate=86.595000000, + qty=2, + do_not_save=True, + ) + pos.is_pos = 1 + pos.pos_profile = pos_profile.name + pos.debit_to = "_Test Receivable USD - _TC" + pos.append("payments", {"mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 20.35}) + pos.save().submit() + + pos_return = make_sales_return(pos.name) + self.assertEqual(abs(pos_return.payments[0].amount), pos.payments[0].amount) + def set_advance_flag(company, flag, default_account): frappe.db.set_value( diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 53a2e279a4d..3215b93a496 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -87,6 +87,7 @@ def get_party_details(inv): def get_party_tax_withholding_details(inv, tax_withholding_category=None): if inv.doctype == "Payment Entry": inv.tax_withholding_net_total = inv.net_total + inv.base_tax_withholding_net_total = inv.net_total pan_no = "" parties = [] @@ -326,7 +327,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N # once tds is deducted, not need to add vouchers in the invoice voucher_wise_amount = {} else: - tax_amount = get_tds_amount(ldc, parties, inv, tax_details, vouchers) + tax_amount = get_tds_amount(ldc, parties, inv, tax_details, voucher_wise_amount) elif party_type == "Customer": if tax_deducted: @@ -356,13 +357,16 @@ def is_tax_deducted_on_the_basis_of_inv(vouchers): def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"): - doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice" - field = ( - "base_tax_withholding_net_total as base_net_total" if party_type == "Supplier" else "base_net_total" - ) - voucher_wise_amount = {} + voucher_wise_amount = [] vouchers = [] + doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice" + field = [ + "base_tax_withholding_net_total as base_net_total" if party_type == "Supplier" else "base_net_total", + "name", + "grand_total", + ] + filters = { "company": company, frappe.scrub(party_type): ["in", parties], @@ -376,15 +380,24 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"): {"apply_tds": 1, "tax_withholding_category": tax_details.get("tax_withholding_category")} ) - invoices_details = frappe.get_all(doctype, filters=filters, fields=["name", field]) + invoices_details = frappe.get_all(doctype, filters=filters, fields=field) for d in invoices_details: vouchers.append(d.name) - voucher_wise_amount.update({d.name: {"amount": d.base_net_total, "voucher_type": doctype}}) + voucher_wise_amount.append( + frappe._dict( + { + "voucher_name": d.name, + "voucher_type": doctype, + "taxable_amount": d.base_net_total, + "grand_total": d.grand_total, + } + ) + ) journal_entries_details = frappe.db.sql( """ - SELECT j.name, ja.credit - ja.debit AS amount + SELECT j.name, ja.credit - ja.debit AS amount, ja.reference_type FROM `tabJournal Entry` j, `tabJournal Entry Account` ja WHERE j.name = ja.parent @@ -403,13 +416,20 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"): tax_details.get("tax_withholding_category"), company, ), - as_dict=1, ) - if journal_entries_details: - for d in journal_entries_details: - vouchers.append(d.name) - voucher_wise_amount.update({d.name: {"amount": d.amount, "voucher_type": "Journal Entry"}}) + for d in journal_entries_details: + vouchers.append(d.name) + voucher_wise_amount.append( + frappe._dict( + { + "voucher_name": d.name, + "voucher_type": "Journal Entry", + "taxable_amount": d.amount, + "reference_type": d.reference_type, + } + ) + ) return vouchers, voucher_wise_amount @@ -508,12 +528,24 @@ def get_advance_tax_across_fiscal_year(tax_deducted_on_advances, tax_details): return advance_tax_from_across_fiscal_year -def get_tds_amount(ldc, parties, inv, tax_details, vouchers): +def get_tds_amount(ldc, parties, inv, tax_details, voucher_wise_amount): tds_amount = 0 - invoice_filters = {"name": ("in", vouchers), "docstatus": 1, "apply_tds": 1} + + pi_grand_total = 0 + pi_base_net_total = 0 + jv_credit_amt = 0 + pe_credit_amt = 0 + + for row in voucher_wise_amount: + if row.voucher_type == "Purchase Invoice": + pi_grand_total += row.get("grand_total", 0) + pi_base_net_total += row.get("taxable_amount", 0) + + if row.voucher_type == "Journal Entry" and row.reference_type != "Purchase Invoice": + jv_credit_amt += row.get("taxable_amount", 0) ## for TDS to be deducted on advances - payment_entry_filters = { + pe_filters = { "party_type": "Supplier", "party": ("in", parties), "docstatus": 1, @@ -524,70 +556,49 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers): "company": inv.company, } - field = "sum(tax_withholding_net_total)" + consider_party_ledger_amt = cint(tax_details.consider_party_ledger_amount) - if cint(tax_details.consider_party_ledger_amount): - invoice_filters.pop("apply_tds", None) - field = "sum(grand_total)" - - payment_entry_filters.pop("apply_tax_withholding_amount", None) - payment_entry_filters.pop("tax_withholding_category", None) - - supp_inv_credit_amt = frappe.db.get_value("Purchase Invoice", invoice_filters, field) or 0.0 - - supp_jv_credit_amt = ( - frappe.db.get_value( - "Journal Entry Account", - { - "parent": ("in", vouchers), - "docstatus": 1, - "party": ("in", parties), - "reference_type": ("!=", "Purchase Invoice"), - }, - "sum(credit_in_account_currency - debit_in_account_currency)", - ) - or 0.0 - ) + if consider_party_ledger_amt: + pe_filters.pop("apply_tax_withholding_amount", None) + pe_filters.pop("tax_withholding_category", None) # Get Amount via payment entry - payment_entry_amounts = frappe.db.get_all( + payment_entries = frappe.db.get_all( "Payment Entry", - filters=payment_entry_filters, - fields=["sum(unallocated_amount) as amount", "payment_type"], - group_by="payment_type", + filters=pe_filters, + fields=["name", "unallocated_amount as taxable_amount", "payment_type"], ) - supp_credit_amt = supp_jv_credit_amt - supp_credit_amt += inv.get("tax_withholding_net_total", 0) - - for type in payment_entry_amounts: - if type.payment_type == "Pay": - supp_credit_amt += type.amount - else: - supp_credit_amt -= type.amount + for row in payment_entries: + value = row.taxable_amount if row.payment_type == "Pay" else -1 * row.taxable_amount + pe_credit_amt += value + voucher_wise_amount.append( + frappe._dict( + { + "voucher_name": row.name, + "voucher_type": "Payment Entry", + "taxable_amount": value, + } + ) + ) threshold = tax_details.get("threshold", 0) cumulative_threshold = tax_details.get("cumulative_threshold", 0) + supp_credit_amt = jv_credit_amt + pe_credit_amt + inv.get("tax_withholding_net_total", 0) + tax_withholding_net_total = inv.get("base_tax_withholding_net_total", 0) - if inv.doctype != "Payment Entry": - tax_withholding_net_total = inv.get("base_tax_withholding_net_total", 0) - else: - tax_withholding_net_total = inv.get("tax_withholding_net_total", 0) + # if consider_party_ledger_amount is checked, then threshold will be based on grand total + amt_for_threshold = pi_grand_total if consider_party_ledger_amt else pi_base_net_total - has_cumulative_threshold_breached = ( - cumulative_threshold and (supp_credit_amt + supp_inv_credit_amt) >= cumulative_threshold + cumulative_threshold_breached = ( + cumulative_threshold and (supp_credit_amt + amt_for_threshold) >= cumulative_threshold ) - if (threshold and tax_withholding_net_total >= threshold) or (has_cumulative_threshold_breached): - # Get net total again as TDS is calculated on net total - # Grand is used to just check for threshold breach - net_total = ( - frappe.db.get_value("Purchase Invoice", invoice_filters, "sum(tax_withholding_net_total)") or 0.0 - ) - supp_credit_amt += net_total + if (threshold and tax_withholding_net_total >= threshold) or (cumulative_threshold_breached): + supp_credit_amt += pi_base_net_total - if has_cumulative_threshold_breached and cint(tax_details.tax_on_excess_amount): - supp_credit_amt = net_total + tax_withholding_net_total - cumulative_threshold + if cumulative_threshold_breached and cint(tax_details.tax_on_excess_amount): + supp_credit_amt = pi_base_net_total + tax_withholding_net_total - cumulative_threshold if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0): tds_amount = get_lower_deduction_amount( diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index f2ebfc60cd7..c4ab2f94581 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -569,6 +569,15 @@ class TestTaxWithholdingCategory(FrappeTestCase): pi1.submit() invoices.append(pi1) + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=1000 + ) + pe.apply_tax_withholding_amount = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save() + pe.submit() + invoices.append(pe) + pi2 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=9000, do_not_save=True) pi2.apply_tds = 1 pi2.tax_withholding_category = "Test Multi Invoice Category" @@ -584,6 +593,8 @@ class TestTaxWithholdingCategory(FrappeTestCase): self.assertTrue(pi2.tax_withheld_vouchers[0].taxable_amount == pi1.net_total) self.assertTrue(pi2.tax_withheld_vouchers[1].voucher_name == pi.name) self.assertTrue(pi2.tax_withheld_vouchers[1].taxable_amount == pi.net_total) + self.assertTrue(pi2.tax_withheld_vouchers[2].voucher_name == pe.name) + self.assertTrue(pi2.tax_withheld_vouchers[2].taxable_amount == pe.paid_amount) # cancel invoices to avoid clashing for d in reversed(invoices): diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 19da840f543..e5e43aefa32 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -35,7 +35,7 @@ def make_gl_entries( make_acc_dimensions_offsetting_entry(gl_map) validate_accounting_period(gl_map) validate_disabled_accounts(gl_map) - gl_map = process_gl_map(gl_map, merge_entries) + gl_map = process_gl_map(gl_map, merge_entries, from_repost=from_repost) if gl_map and len(gl_map) > 1: if gl_map[0].voucher_type != "Period Closing Voucher": create_payment_ledger_entry( @@ -163,12 +163,12 @@ def validate_accounting_period(gl_map): ) -def process_gl_map(gl_map, merge_entries=True, precision=None): +def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False): if not gl_map: return [] if gl_map[0].voucher_type != "Period Closing Voucher": - gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision) + gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision, from_repost) if merge_entries: gl_map = merge_similar_entries(gl_map, precision) @@ -178,13 +178,17 @@ def process_gl_map(gl_map, merge_entries=True, precision=None): return gl_map -def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None): +def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_repost=False): new_gl_map = [] for d in gl_map: cost_center = d.get("cost_center") # Validate budget against main cost center - validate_expense_against_budget(d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)) + if not from_repost: + validate_expense_against_budget( + d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision) + ) + cost_center_allocation = get_cost_center_allocation_data( gl_map[0]["company"], gl_map[0]["posting_date"], cost_center ) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index a7f8581e0f8..b3c82e84192 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1644,7 +1644,7 @@ def get_stock_and_account_balance(account=None, posting_date=None, company=None) if wh_details.account == account and not wh_details.is_group ] - total_stock_value = get_stock_value_on(related_warehouses, posting_date) + total_stock_value = get_stock_value_on(related_warehouses, posting_date, company=company) precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") return flt(account_balance, precision), flt(total_stock_value, precision), related_warehouses diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js index 83b5c376ac7..3f7a2e7c7d8 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js @@ -34,6 +34,7 @@ frappe.ui.form.on("Depreciation Schedule", { asset_depr_schedule_name: frm.doc.name, date: row.schedule_date, }, + debounce: 1000, callback: function (r) { frappe.model.sync(r.message); frm.refresh(); diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js index 7b37987b926..3c357c0a933 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js @@ -44,16 +44,22 @@ frappe.listview_settings["Purchase Order"] = { listview.call_for_selected_items(method, { status: "Submitted" }); }); - listview.page.add_action_item(__("Purchase Invoice"), () => { - erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Invoice"); - }); + if (frappe.model.can_create("Purchase Invoice")) { + listview.page.add_action_item(__("Purchase Invoice"), () => { + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Invoice"); + }); + } - listview.page.add_action_item(__("Purchase Receipt"), () => { - erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Receipt"); - }); + if (frappe.model.can_create("Purchase Receipt")) { + listview.page.add_action_item(__("Purchase Receipt"), () => { + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Receipt"); + }); + } - listview.page.add_action_item(__("Advance Payment"), () => { - erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Payment Entry"); - }); + if (frappe.model.can_create("Payment Entry")) { + listview.page.add_action_item(__("Advance Payment"), () => { + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Payment Entry"); + }); + } }, }; diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js index 99fe24d8770..1a2a514a680 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js @@ -11,12 +11,20 @@ frappe.listview_settings["Supplier Quotation"] = { }, onload: function (listview) { - listview.page.add_action_item(__("Purchase Order"), () => { - erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Order"); - }); + if (frappe.model.can_create("Purchase Order")) { + listview.page.add_action_item(__("Purchase Order"), () => { + erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Order"); + }); + } - listview.page.add_action_item(__("Purchase Invoice"), () => { - erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Invoice"); - }); + if (frappe.model.can_create("Purchase Invoice")) { + listview.page.add_action_item(__("Purchase Invoice"), () => { + erpnext.bulk_transaction_processing.create( + listview, + "Supplier Quotation", + "Purchase Invoice" + ); + }); + } }, }; diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index b687914600b..2aadd6b584e 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -8,7 +8,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.query_builder import Criterion +from frappe.query_builder import Criterion, DocType from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Abs, Sum from frappe.utils import ( @@ -252,6 +252,8 @@ class AccountsController(TransactionBase): self.validate_deferred_income_expense_account() self.set_inter_company_account() + self.set_taxes_and_charges() + if self.doctype == "Purchase Invoice": self.calculate_paid_amount() # apply tax withholding only if checked and applicable @@ -266,6 +268,7 @@ class AccountsController(TransactionBase): self.set_total_in_words() self.set_default_letter_head() + self.validate_company_in_accounting_dimension() def set_default_letter_head(self): if hasattr(self, "letter_head") and not self.letter_head: @@ -403,6 +406,39 @@ class AccountsController(TransactionBase): for row in batches: frappe.delete_doc("Batch", row.name) + def validate_company_in_accounting_dimension(self): + doc_field = DocType("DocField") + accounting_dimension = DocType("Accounting Dimension") + dimension_list = ( + frappe.qb.from_(accounting_dimension) + .select(accounting_dimension.document_type) + .join(doc_field) + .on(doc_field.parent == accounting_dimension.document_type) + .where(doc_field.fieldname == "company") + ).run(as_list=True) + + dimension_list = sum(dimension_list, ["Project"]) + self.validate_company(dimension_list) + + for child in self.get_all_children() or []: + self.validate_company(dimension_list, child) + + def validate_company(self, dimension_list, child=None): + for dimension in dimension_list: + if not child: + dimension_value = self.get(frappe.scrub(dimension)) + else: + dimension_value = child.get(frappe.scrub(dimension)) + + if dimension_value: + company = frappe.get_cached_value(dimension, dimension_value, "company") + if company and company != self.company: + frappe.throw( + _("{0}: {1} does not belong to the Company: {2}").format( + dimension, frappe.bold(dimension_value), self.company + ) + ) + def validate_return_against_account(self): if self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against: cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to" @@ -935,6 +971,12 @@ class AccountsController(TransactionBase): ): return True + def set_taxes_and_charges(self): + if frappe.db.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + if hasattr(self, "taxes_and_charges") and not self.get("taxes") and not self.get("is_pos"): + if tax_master_doctype := self.meta.get_field("taxes_and_charges").options: + self.append_taxes_from_master(tax_master_doctype) + def append_taxes_from_master(self, tax_master_doctype=None): if self.get("taxes_and_charges"): if not tax_master_doctype: @@ -2413,10 +2455,15 @@ class AccountsController(TransactionBase): ) if ( - flt(total, self.precision("grand_total")) - flt(grand_total, self.precision("grand_total")) + abs( + flt(total, self.precision("grand_total")) + - flt(grand_total, self.precision("grand_total")) + ) > 0.1 - or flt(base_total, self.precision("base_grand_total")) - - flt(base_grand_total, self.precision("base_grand_total")) + or abs( + flt(base_total, self.precision("base_grand_total")) + - flt(base_grand_total, self.precision("base_grand_total")) + ) > 0.1 ): frappe.throw( @@ -3686,6 +3733,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ).format(frappe.bold(parent.name)) ) else: # Sales Order + parent.validate_for_duplicate_items() parent.validate_warehouse() parent.update_reserved_qty() parent.update_project() diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 4d44f493b1d..66ec8851727 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -174,7 +174,11 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items): ) for column in fields: - returned_qty = flt(already_returned_data.get(column, 0)) if len(already_returned_data) > 0 else 0 + returned_qty = ( + flt(already_returned_data.get(column, 0), stock_qty_precision) + if len(already_returned_data) > 0 + else 0 + ) if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"): reference_qty = ref.get(column) @@ -186,7 +190,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items): reference_qty = ref.get(column) * ref.get("conversion_factor", 1.0) current_stock_qty = args.get(column) * args.get("conversion_factor", 1.0) - max_returnable_qty = flt(reference_qty, stock_qty_precision) - returned_qty + max_returnable_qty = flt(flt(reference_qty, stock_qty_precision) - returned_qty, stock_qty_precision) label = column.replace("_", " ").title() if reference_qty: @@ -370,6 +374,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai if doc.get("is_return"): if doc.doctype == "Sales Invoice" or doc.doctype == "POS Invoice": doc.consolidated_invoice = "" + # no copy enabled for party_account_currency + doc.party_account_currency = source.party_account_currency doc.set("payments", []) doc.update_billed_amount_in_delivery_note = True for data in source.payments: @@ -1150,3 +1156,9 @@ def get_available_serial_nos(serial_nos, warehouse): return frappe.get_all( "Serial No", filters={"warehouse": warehouse, "name": ("in", serial_nos)}, pluck="name" ) + + +@frappe.whitelist() +def get_payment_data(invoice): + payment = frappe.db.get_all("Sales Invoice Payment", {"parent": invoice}, ["mode_of_payment", "amount"]) + return payment diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index b704cb30791..a9258204b39 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -714,6 +714,16 @@ class SellingController(StockController): if self.doctype == "POS Invoice": return + items = [item.item_code for item in self.get("items")] + item_stock_map = frappe._dict( + frappe.get_all( + "Item", + filters={"item_code": ["in", items]}, + fields=["item_code", "is_stock_item"], + as_list=True, + ) + ) + for d in self.get("items"): if self.doctype == "Sales Invoice": stock_items = [ @@ -747,7 +757,7 @@ class SellingController(StockController): frappe.bold(_("Allow Item to Be Added Multiple Times in a Transaction")), get_link_to_form("Selling Settings", "Selling Settings"), ) - if frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1: + if item_stock_map.get(d.item_code): if stock_items in check_list: frappe.throw(duplicate_items_msg) else: diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index c45923b2fed..2c46f04af71 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -17,6 +17,7 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.party import get_party_account from erpnext.buying.doctype.purchase_order.test_purchase_order import prepare_data_for_internal_transfer +from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import create_item @@ -1532,32 +1533,32 @@ class TestAccountsController(FrappeTestCase): # Invoices si1 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) - si1.department = "Management" + si1.department = "Management - _TC" si1.save().submit() si2 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) - si2.department = "Operations" + si2.department = "Operations - _TC" si2.save().submit() # Payments cr_note1 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) - cr_note1.department = "Management" + cr_note1.department = "Management - _TC" cr_note1.is_return = 1 cr_note1.save().submit() cr_note2 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) - cr_note2.department = "Legal" + cr_note2.department = "Legal - _TC" cr_note2.is_return = 1 cr_note2.save().submit() pe1 = get_payment_entry(si1.doctype, si1.name) pe1.references = [] - pe1.department = "Research & Development" + pe1.department = "Research & Development - _TC" pe1.save().submit() pe2 = get_payment_entry(si1.doctype, si1.name) pe2.references = [] - pe2.department = "Management" + pe2.department = "Management - _TC" pe2.save().submit() je1 = self.create_journal_entry( @@ -1570,7 +1571,7 @@ class TestAccountsController(FrappeTestCase): ) je1.accounts[0].party_type = "Customer" je1.accounts[0].party = self.customer - je1.accounts[0].department = "Management" + je1.accounts[0].department = "Management - _TC" je1.save().submit() # assert dimension filter's result @@ -1579,17 +1580,17 @@ class TestAccountsController(FrappeTestCase): self.assertEqual(len(pr.invoices), 2) self.assertEqual(len(pr.payments), 5) - pr.department = "Legal" + pr.department = "Legal - _TC" pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 0) self.assertEqual(len(pr.payments), 1) - pr.department = "Management" + pr.department = "Management - _TC" pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 3) - pr.department = "Research & Development" + pr.department = "Research & Development - _TC" pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 0) self.assertEqual(len(pr.payments), 1) @@ -1600,17 +1601,17 @@ class TestAccountsController(FrappeTestCase): # Invoice si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) - si.department = "Management" + si.department = "Management - _TC" si.save().submit() # Payment cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) - cr_note.department = "Management" + cr_note.department = "Management - _TC" cr_note.is_return = 1 cr_note.save().submit() pr = self.create_payment_reconciliation() - pr.department = "Management" + pr.department = "Management - _TC" pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) @@ -1642,7 +1643,7 @@ class TestAccountsController(FrappeTestCase): # Sales Invoice in Foreign Currency self.setup_dimensions() rate_in_account_currency = 1 - dpt = "Research & Development" + dpt = "Research & Development - _TC" si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_save=True) si.department = dpt @@ -1677,7 +1678,7 @@ class TestAccountsController(FrappeTestCase): def test_93_dimension_inheritance_on_advance(self): self.setup_dimensions() - dpt = "Research & Development" + dpt = "Research & Development - _TC" adv = self.create_payment_entry(amount=1, source_exc_rate=85) adv.department = dpt @@ -2135,3 +2136,13 @@ class TestAccountsController(FrappeTestCase): journal_voucher = frappe.get_doc("Journal Entry", exc_je_for_pi[0].parent) purchase_invoice = frappe.get_doc("Purchase Invoice", pi.name) self.assertEqual(purchase_invoice.advances[0].difference_posting_date, journal_voucher.posting_date) + + def test_company_validation_in_dimension(self): + si = create_sales_invoice(do_not_submit=True) + project = make_project({"project_name": "_Test Demo Project1", "company": "_Test Company 1"}) + si.project = project.name + self.assertRaises(frappe.ValidationError, si.save) + + si_1 = create_sales_invoice(do_not_submit=True) + si_1.items[0].project = project.name + self.assertRaises(frappe.ValidationError, si_1.save) diff --git a/erpnext/crm/report/lead_details/lead_details.py b/erpnext/crm/report/lead_details/lead_details.py index 98dfbec18be..608be6ec912 100644 --- a/erpnext/crm/report/lead_details/lead_details.py +++ b/erpnext/crm/report/lead_details/lead_details.py @@ -55,12 +55,13 @@ def get_columns(): "options": "Company", "width": 120, }, - {"fieldname": "address", "label": _("Address"), "fieldtype": "Data", "width": 130}, - {"fieldname": "state", "label": _("State"), "fieldtype": "Data", "width": 100}, - {"fieldname": "pincode", "label": _("Postal Code"), "fieldtype": "Data", "width": 90}, + {"label": _("Address"), "fieldname": "address", "fieldtype": "Data", "width": 130}, + {"label": _("Postal Code"), "fieldname": "pincode", "fieldtype": "Data", "width": 90}, + {"label": _("City"), "fieldname": "city", "fieldtype": "Data", "width": 100}, + {"label": _("State"), "fieldname": "state", "fieldtype": "Data", "width": 100}, { - "fieldname": "country", "label": _("Country"), + "fieldname": "country", "fieldtype": "Link", "options": "Country", "width": 100, @@ -93,8 +94,9 @@ def get_data(filters): lead.owner, lead.company, (Concat_ws(", ", address.address_line1, address.address_line2)).as_("address"), - address.state, address.pincode, + address.city, + address.state, address.country, ) .where(lead.company == filters.company) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index f44fad333cf..cb9f49e8c28 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -69,23 +69,7 @@ class PlaidConnector: else: return response["link_token"] - def auth(self): - try: - self.client.Auth.get(self.access_token) - except ItemError as e: - if e.code == "ITEM_LOGIN_REQUIRED": - pass - except APIError as e: - if e.code == "PLANNED_MAINTENANCE": - pass - except requests.Timeout: - pass - except Exception as e: - frappe.log_error("Plaid: Authentication error") - frappe.throw(_(str(e)), title=_("Authentication Failed")) - def get_transactions(self, start_date, end_date, account_id=None): - self.auth() kwargs = dict(access_token=self.access_token, start_date=start_date, end_date=end_date) if account_id: kwargs.update(dict(account_ids=[account_id])) diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js deleted file mode 100644 index f9364edf5aa..00000000000 --- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on("QuickBooks Migrator", { - connect: function (frm) { - // OAuth requires user intervention to provide application access permissionsto requested scope - // Here we open a new window and redirect user to the authorization url. - // After user grants us permission to access. We will set authorization details on this doc which will force refresh. - window.open(frm.doc.authorization_url); - }, - fetch_data: function (frm) { - frm.call("migrate"); - }, - onload: function (frm) { - frm.trigger("set_indicator"); - var domain = frappe.urllib.get_base_url(); - var redirect_url = `${domain}/api/method/erpnext.erpnext_integrations.doctype.quickbooks_migrator.quickbooks_migrator.callback`; - if (frm.doc.redirect_url != redirect_url) { - frm.set_value("redirect_url", redirect_url); - } - // Instead of changing percentage width and message of single progress bar - // Show a different porgress bar for every action after some time remove the finished progress bar - // Former approach causes the progress bar to dance back and forth. - frm.trigger("set_indicator"); - frappe.realtime.on("quickbooks_progress_update", function (data) { - frm.dashboard.show_progress(data.message, (data.count / data.total) * 100, data.message); - if (data.count == data.total) { - window.setTimeout( - function (message) { - frm.dashboard.hide_progress(message); - }, - 1500, - data.messsage - ); - } - }); - }, - refresh: function (frm) { - frm.trigger("set_indicator"); - if (!frm.doc.access_token) { - // Unset access_token signifies that we don't have enough information to connect to quickbooks api and fetch data - if (frm.doc.authorization_url) { - frm.add_custom_button(__("Connect to Quickbooks"), function () { - frm.trigger("connect"); - }); - } - } - if (frm.doc.access_token) { - // If we have access_token that means we also have refresh_token we don't need user intervention anymore - // All we need now is a Company from erpnext - frm.remove_custom_button(__("Connect to Quickbooks")); - - frm.toggle_display("company_settings", 1); - frm.set_df_property("company", "reqd", 1); - if (frm.doc.company) { - frm.add_custom_button(__("Fetch Data"), function () { - frm.trigger("fetch_data"); - }); - } - } - }, - set_indicator: function (frm) { - var indicator_map = { - "Connecting to QuickBooks": [__("Connecting to QuickBooks"), "orange"], - "Connected to QuickBooks": [__("Connected to QuickBooks"), "green"], - "In Progress": [__("In Progress"), "orange"], - Complete: [__("Complete"), "green"], - Failed: [__("Failed"), "red"], - }; - if (frm.doc.status) { - var indicator = indicator_map[frm.doc.status]; - var label = indicator[0]; - var color = indicator[1]; - frm.page.set_indicator(label, color); - } - }, -}); diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json deleted file mode 100644 index 5428177914b..00000000000 --- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "beta": 1, - "creation": "2018-07-10 14:48:16.757030", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "status", - "application_settings", - "client_id", - "redirect_url", - "token_endpoint", - "application_column_break", - "client_secret", - "scope", - "api_endpoint", - "authorization_settings", - "authorization_endpoint", - "refresh_token", - "code", - "authorization_column_break", - "authorization_url", - "access_token", - "quickbooks_company_id", - "company_settings", - "company", - "default_shipping_account", - "default_warehouse", - "company_column_break", - "default_cost_center", - "undeposited_funds_account" - ], - "fields": [ - { - "fieldname": "status", - "fieldtype": "Select", - "hidden": 1, - "label": "Status", - "options": "Connecting to QuickBooks\nConnected to QuickBooks\nIn Progress\nComplete\nFailed" - }, - { - "collapsible": 1, - "collapsible_depends_on": "eval:doc.client_id && doc.client_secret && doc.redirect_url", - "fieldname": "application_settings", - "fieldtype": "Section Break", - "label": "Application Settings" - }, - { - "fieldname": "client_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Client ID", - "reqd": 1 - }, - { - "fieldname": "redirect_url", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Redirect URL", - "reqd": 1 - }, - { - "default": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", - "fieldname": "token_endpoint", - "fieldtype": "Data", - "label": "Token Endpoint", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "application_column_break", - "fieldtype": "Column Break" - }, - { - "fieldname": "client_secret", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Client Secret", - "reqd": 1 - }, - { - "default": "com.intuit.quickbooks.accounting", - "fieldname": "scope", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Scope", - "read_only": 1, - "reqd": 1 - }, - { - "default": "https://quickbooks.api.intuit.com/v3", - "fieldname": "api_endpoint", - "fieldtype": "Data", - "label": "API Endpoint", - "read_only": 1, - "reqd": 1 - }, - { - "collapsible": 1, - "fieldname": "authorization_settings", - "fieldtype": "Section Break", - "label": "Authorization Settings" - }, - { - "default": "https://appcenter.intuit.com/connect/oauth2", - "fieldname": "authorization_endpoint", - "fieldtype": "Data", - "label": "Authorization Endpoint", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "refresh_token", - "fieldtype": "Small Text", - "hidden": 1, - "label": "Refresh Token" - }, - { - "fieldname": "code", - "fieldtype": "Data", - "hidden": 1, - "label": "Code" - }, - { - "fieldname": "authorization_column_break", - "fieldtype": "Column Break" - }, - { - "fieldname": "authorization_url", - "fieldtype": "Data", - "label": "Authorization URL", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Small Text", - "hidden": 1, - "label": "Access Token" - }, - { - "fieldname": "quickbooks_company_id", - "fieldtype": "Data", - "hidden": 1, - "label": "Quickbooks Company ID" - }, - { - "fieldname": "company_settings", - "fieldtype": "Section Break", - "hidden": 1, - "label": "Company Settings" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company" - }, - { - "fieldname": "default_shipping_account", - "fieldtype": "Link", - "hidden": 1, - "label": "Default Shipping Account", - "options": "Account" - }, - { - "fieldname": "default_warehouse", - "fieldtype": "Link", - "hidden": 1, - "label": "Default Warehouse", - "options": "Warehouse" - }, - { - "fieldname": "company_column_break", - "fieldtype": "Column Break" - }, - { - "fieldname": "default_cost_center", - "fieldtype": "Link", - "hidden": 1, - "label": "Default Cost Center", - "options": "Cost Center" - }, - { - "fieldname": "undeposited_funds_account", - "fieldtype": "Link", - "hidden": 1, - "label": "Undeposited Funds Account", - "options": "Account" - } - ], - "issingle": 1, - "modified": "2019-08-07 15:26:00.653433", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "QuickBooks Migrator", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC" -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py deleted file mode 100644 index 5175cbdf6a9..00000000000 --- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py +++ /dev/null @@ -1,1421 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import json -import traceback - -import frappe -import requests -from frappe import _ -from frappe.model.document import Document -from requests_oauthlib import OAuth2Session - -from erpnext import encode_company_abbr - - -# QuickBooks requires a redirect URL, User will be redirect to this URL -# This will be a GET request -# Request parameters will have two parameters `code` and `realmId` -# `code` is required to acquire refresh_token and access_token -# `realmId` is the QuickBooks Company ID. It is Needed to actually fetch data. -@frappe.whitelist() -def callback(*args, **kwargs): - migrator = frappe.get_doc("QuickBooks Migrator") - migrator.set_indicator("Connecting to QuickBooks") - migrator.code = kwargs.get("code") - migrator.quickbooks_company_id = kwargs.get("realmId") - migrator.save() - migrator.get_tokens() - frappe.db.commit() - migrator.set_indicator("Connected to QuickBooks") - # We need this page to automatically close afterwards - frappe.respond_as_web_page("Quickbooks Authentication", html="") - - -class QuickBooksMigrator(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - access_token: DF.SmallText | None - api_endpoint: DF.Data - authorization_endpoint: DF.Data - authorization_url: DF.Data - client_id: DF.Data - client_secret: DF.Data - code: DF.Data | None - company: DF.Link | None - default_cost_center: DF.Link | None - default_shipping_account: DF.Link | None - default_warehouse: DF.Link | None - quickbooks_company_id: DF.Data | None - redirect_url: DF.Data - refresh_token: DF.SmallText | None - scope: DF.Data - status: DF.Literal[ - "Connecting to QuickBooks", "Connected to QuickBooks", "In Progress", "Complete", "Failed" - ] - token_endpoint: DF.Data - undeposited_funds_account: DF.Link | None - # end: auto-generated types - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.oauth = OAuth2Session(client_id=self.client_id, redirect_uri=self.redirect_url, scope=self.scope) - if not self.authorization_url and self.authorization_endpoint: - self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] - - def on_update(self): - if self.company: - # We need a Cost Center corresponding to the selected erpnext Company - self.default_cost_center = frappe.db.get_value("Company", self.company, "cost_center") - company_warehouses = frappe.get_all("Warehouse", filters={"company": self.company, "is_group": 0}) - if company_warehouses: - self.default_warehouse = company_warehouses[0].name - if self.authorization_endpoint: - self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] - - @frappe.whitelist() - def migrate(self): - frappe.enqueue_doc("QuickBooks Migrator", "QuickBooks Migrator", "_migrate", queue="long") - - def _migrate(self): - try: - self.set_indicator("In Progress") - # Add quickbooks_id field to every document so that we can lookup by Id reference - # provided by documents in API responses. - # Also add a company field to Customer Supplier and Item - self._make_custom_fields() - - self._migrate_accounts() - - # Some Quickbooks Entities like Advance Payment, Payment aren't available firectly from API - # Sales Invoice also sometimes needs to be saved as a Journal Entry - # (When Item table is not present, This appens when Invoice is attached with a "StatementCharge" "ReimburseCharge - # Details of both of these cannot be fetched from API) - # Their GL entries need to be generated from GeneralLedger Report. - self._fetch_general_ledger() - - # QuickBooks data can have transactions that do not fall in existing fiscal years in ERPNext - self._create_fiscal_years() - - self._allow_fraction_in_unit() - - # Following entities are directly available from API - # Invoice can be an exception sometimes though (as explained above). - entities_for_normal_transform = [ - "Customer", - "Item", - "Vendor", - "Preferences", - "JournalEntry", - "Purchase", - "Deposit", - "Invoice", - "CreditMemo", - "SalesReceipt", - "RefundReceipt", - "Bill", - "VendorCredit", - "Payment", - "BillPayment", - ] - for entity in entities_for_normal_transform: - self._migrate_entries(entity) - - # Following entries are not available directly from API, Need to be regenrated from GeneralLedger Report - entities_for_gl_transform = [ - "Advance Payment", - "Tax Payment", - "Sales Tax Payment", - "Purchase Tax Payment", - "Inventory Qty Adjust", - ] - for entity in entities_for_gl_transform: - self._migrate_entries_from_gl(entity) - self.set_indicator("Complete") - except Exception as e: - self.set_indicator("Failed") - self._log_error(e) - - frappe.db.commit() - - def get_tokens(self): - token = self.oauth.fetch_token( - token_url=self.token_endpoint, client_secret=self.client_secret, code=self.code - ) - self.access_token = token["access_token"] - self.refresh_token = token["refresh_token"] - self.save() - - def _refresh_tokens(self): - token = self.oauth.refresh_token( - token_url=self.token_endpoint, - client_id=self.client_id, - refresh_token=self.refresh_token, - client_secret=self.client_secret, - code=self.code, - ) - self.access_token = token["access_token"] - self.refresh_token = token["refresh_token"] - self.save() - - def _make_custom_fields(self): - doctypes_for_quickbooks_id_field = [ - "Account", - "Customer", - "Address", - "Item", - "Supplier", - "Sales Invoice", - "Journal Entry", - "Purchase Invoice", - ] - for doctype in doctypes_for_quickbooks_id_field: - self._make_custom_quickbooks_id_field(doctype) - - doctypes_for_company_field = ["Customer", "Item", "Supplier"] - for doctype in doctypes_for_company_field: - self._make_custom_company_field(doctype) - - frappe.db.commit() - - def _make_custom_quickbooks_id_field(self, doctype): - if not frappe.get_meta(doctype).has_field("quickbooks_id"): - frappe.get_doc( - { - "doctype": "Custom Field", - "label": "QuickBooks ID", - "dt": doctype, - "fieldname": "quickbooks_id", - "fieldtype": "Data", - } - ).insert() - - def _make_custom_company_field(self, doctype): - if not frappe.get_meta(doctype).has_field("company"): - frappe.get_doc( - { - "doctype": "Custom Field", - "label": "Company", - "dt": doctype, - "fieldname": "company", - "fieldtype": "Link", - "options": "Company", - } - ).insert() - - def _migrate_accounts(self): - self._make_root_accounts() - for entity in ["Account", "TaxRate", "TaxCode"]: - self._migrate_entries(entity) - - def _make_root_accounts(self): - roots = ["Asset", "Equity", "Expense", "Liability", "Income"] - for root in roots: - try: - if not frappe.db.exists( - { - "doctype": "Account", - "name": encode_company_abbr(f"{root} - QB", self.company), - "company": self.company, - } - ): - frappe.get_doc( - { - "doctype": "Account", - "account_name": f"{root} - QB", - "root_type": root, - "is_group": "1", - "company": self.company, - } - ).insert(ignore_mandatory=True) - except Exception as e: - self._log_error(e, root) - frappe.db.commit() - - def _migrate_entries(self, entity): - try: - query_uri = f"{self.api_endpoint}/company/{self.quickbooks_company_id}/query" - max_result_count = 1000 - # Count number of entries - response = self._get(query_uri, params={"query": f"""SELECT COUNT(*) FROM {entity}"""}) - entry_count = response.json()["QueryResponse"]["totalCount"] - - # fetch pages and accumulate - entries = [] - for start_position in range(1, entry_count + 1, max_result_count): - response = self._get( - query_uri, - params={ - "query": """SELECT * FROM {} STARTPOSITION {} MAXRESULTS {}""".format( - entity, start_position, max_result_count - ) - }, - ) - entries.extend(response.json()["QueryResponse"][entity]) - entries = self._preprocess_entries(entity, entries) - self._save_entries(entity, entries) - except Exception as e: - self._log_error(e, response.text) - - def _fetch_general_ledger(self): - try: - query_uri = f"{self.api_endpoint}/company/{self.quickbooks_company_id}/reports/GeneralLedger" - response = self._get( - query_uri, - params={ - "columns": ",".join(["tx_date", "txn_type", "credit_amt", "debt_amt"]), - "date_macro": "All", - "minorversion": 3, - }, - ) - self.gl_entries = {} - for section in response.json()["Rows"]["Row"]: - if section["type"] == "Section": - self._get_gl_entries_from_section(section) - self.general_ledger = {} - for account in self.gl_entries.values(): - for line in account: - type_dict = self.general_ledger.setdefault(line["type"], {}) - if line["id"] not in type_dict: - type_dict[line["id"]] = { - "id": line["id"], - "date": line["date"], - "lines": [], - } - type_dict[line["id"]]["lines"].append(line) - except Exception as e: - self._log_error(e, response.text) - - def _create_fiscal_years(self): - try: - # Assumes that exactly one fiscal year has been created so far - # Creates fiscal years till oldest ledger entry date is covered - from itertools import chain - - from frappe.utils.data import add_years, getdate - - smallest_ledger_entry_date = getdate( - min(entry["date"] for entry in chain(*self.gl_entries.values()) if entry["date"]) - ) - oldest_fiscal_year = frappe.get_all( - "Fiscal Year", fields=["year_start_date", "year_end_date"], order_by="year_start_date" - )[0] - # Keep on creating fiscal years - # until smallest_ledger_entry_date is no longer smaller than the oldest fiscal year's start date - while smallest_ledger_entry_date < oldest_fiscal_year.year_start_date: - new_fiscal_year = frappe.get_doc({"doctype": "Fiscal Year"}) - new_fiscal_year.year_start_date = add_years(oldest_fiscal_year.year_start_date, -1) - new_fiscal_year.year_end_date = add_years(oldest_fiscal_year.year_end_date, -1) - if new_fiscal_year.year_start_date.year == new_fiscal_year.year_end_date.year: - new_fiscal_year.year = new_fiscal_year.year_start_date.year - else: - new_fiscal_year.year = "{}-{}".format( - new_fiscal_year.year_start_date.year, new_fiscal_year.year_end_date.year - ) - new_fiscal_year.save() - oldest_fiscal_year = new_fiscal_year - - frappe.db.commit() - except Exception as e: - self._log_error(e) - - def _migrate_entries_from_gl(self, entity): - if entity in self.general_ledger: - self._save_entries(entity, self.general_ledger[entity].values()) - - def _save_entries(self, entity, entries): - entity_method_map = { - "Account": self._save_account, - "TaxRate": self._save_tax_rate, - "TaxCode": self._save_tax_code, - "Preferences": self._save_preference, - "Customer": self._save_customer, - "Item": self._save_item, - "Vendor": self._save_vendor, - "Invoice": self._save_invoice, - "CreditMemo": self._save_credit_memo, - "SalesReceipt": self._save_sales_receipt, - "RefundReceipt": self._save_refund_receipt, - "JournalEntry": self._save_journal_entry, - "Bill": self._save_bill, - "VendorCredit": self._save_vendor_credit, - "Payment": self._save_payment, - "BillPayment": self._save_bill_payment, - "Purchase": self._save_purchase, - "Deposit": self._save_deposit, - "Advance Payment": self._save_advance_payment, - "Tax Payment": self._save_tax_payment, - "Sales Tax Payment": self._save_tax_payment, - "Purchase Tax Payment": self._save_tax_payment, - "Inventory Qty Adjust": self._save_inventory_qty_adjust, - } - total = len(entries) - for index, entry in enumerate(entries, start=1): - self._publish( - { - "event": "progress", - "message": _("Saving {0}").format(entity), - "count": index, - "total": total, - } - ) - entity_method_map[entity](entry) - frappe.db.commit() - - def _preprocess_entries(self, entity, entries): - entity_method_map = { - "Account": self._preprocess_accounts, - "TaxRate": self._preprocess_tax_rates, - "TaxCode": self._preprocess_tax_codes, - } - preprocessor = entity_method_map.get(entity) - if preprocessor: - entries = preprocessor(entries) - return entries - - def _get_gl_entries_from_section(self, section, account=None): - if "Header" in section: - if "id" in section["Header"]["ColData"][0]: - account = self._get_account_name_by_id(section["Header"]["ColData"][0]["id"]) - elif "value" in section["Header"]["ColData"][0] and section["Header"]["ColData"][0]["value"]: - # For some reason during migrating UK company, account id is not available. - # preprocess_accounts retains name:account mapping in self.accounts - # This mapping can then be used to obtain quickbooks_id for correspondong account - # Rest is trivial - - # Some Lines in General Leder Report are shown under Not Specified - # These should be skipped - if section["Header"]["ColData"][0]["value"] == "Not Specified": - return - account_id = self.accounts[section["Header"]["ColData"][0]["value"]]["Id"] - account = self._get_account_name_by_id(account_id) - entries = [] - for row in section["Rows"]["Row"]: - if row["type"] == "Data": - data = row["ColData"] - entries.append( - { - "account": account, - "date": data[0]["value"], - "type": data[1]["value"], - "id": data[1].get("id"), - "credit": frappe.utils.flt(data[2]["value"]), - "debit": frappe.utils.flt(data[3]["value"]), - } - ) - if row["type"] == "Section": - self._get_gl_entries_from_section(row, account) - self.gl_entries.setdefault(account, []).extend(entries) - - def _preprocess_accounts(self, accounts): - self.accounts = {account["Name"]: account for account in accounts} - for account in accounts: - if any(acc["SubAccount"] and acc["ParentRef"]["value"] == account["Id"] for acc in accounts): - account["is_group"] = 1 - else: - account["is_group"] = 0 - return sorted(accounts, key=lambda account: int(account["Id"])) - - def _save_account(self, account): - mapping = { - "Bank": "Asset", - "Other Current Asset": "Asset", - "Fixed Asset": "Asset", - "Other Asset": "Asset", - "Accounts Receivable": "Asset", - "Equity": "Equity", - "Expense": "Expense", - "Other Expense": "Expense", - "Cost of Goods Sold": "Expense", - "Accounts Payable": "Liability", - "Credit Card": "Liability", - "Long Term Liability": "Liability", - "Other Current Liability": "Liability", - "Income": "Income", - "Other Income": "Income", - } - # Map Quickbooks Account Types to ERPNext root_accunts and and root_type - try: - if not frappe.db.exists( - {"doctype": "Account", "quickbooks_id": account["Id"], "company": self.company} - ): - is_child = account["SubAccount"] - is_group = account["is_group"] - # Create Two Accounts for every Group Account - if is_group: - account_id = "Group - {}".format(account["Id"]) - else: - account_id = account["Id"] - - if is_child: - parent_account = self._get_account_name_by_id( - "Group - {}".format(account["ParentRef"]["value"]) - ) - else: - parent_account = encode_company_abbr( - "{} - QB".format(mapping[account["AccountType"]]), self.company - ) - - frappe.get_doc( - { - "doctype": "Account", - "quickbooks_id": account_id, - "account_name": self._get_unique_account_name(account["Name"]), - "root_type": mapping[account["AccountType"]], - "account_type": self._get_account_type(account), - "account_currency": account["CurrencyRef"]["value"], - "parent_account": parent_account, - "is_group": is_group, - "company": self.company, - } - ).insert() - - if is_group: - # Create a Leaf account corresponding to the group account - frappe.get_doc( - { - "doctype": "Account", - "quickbooks_id": account["Id"], - "account_name": self._get_unique_account_name(account["Name"]), - "root_type": mapping[account["AccountType"]], - "account_type": self._get_account_type(account), - "account_currency": account["CurrencyRef"]["value"], - "parent_account": self._get_account_name_by_id(account_id), - "is_group": 0, - "company": self.company, - } - ).insert() - if account.get("AccountSubType") == "UndepositedFunds": - self.undeposited_funds_account = self._get_account_name_by_id(account["Id"]) - self.save() - except Exception as e: - self._log_error(e, account) - - def _get_account_type(self, account): - account_subtype_mapping = {"UndepositedFunds": "Cash"} - account_type = account_subtype_mapping.get(account.get("AccountSubType")) - if account_type is None: - account_type_mapping = { - "Accounts Payable": "Payable", - "Accounts Receivable": "Receivable", - "Bank": "Bank", - "Credit Card": "Bank", - } - account_type = account_type_mapping.get(account["AccountType"]) - return account_type - - def _preprocess_tax_rates(self, tax_rates): - self.tax_rates = {tax_rate["Id"]: tax_rate for tax_rate in tax_rates} - return tax_rates - - def _save_tax_rate(self, tax_rate): - try: - if not frappe.db.exists( - { - "doctype": "Account", - "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), - "company": self.company, - } - ): - frappe.get_doc( - { - "doctype": "Account", - "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), - "account_name": "{} - QB".format(tax_rate["Name"]), - "root_type": "Liability", - "parent_account": encode_company_abbr("{} - QB".format("Liability"), self.company), - "is_group": "0", - "company": self.company, - } - ).insert() - except Exception as e: - self._log_error(e, tax_rate) - - def _preprocess_tax_codes(self, tax_codes): - self.tax_codes = {tax_code["Id"]: tax_code for tax_code in tax_codes} - return tax_codes - - def _save_tax_code(self, tax_code): - pass - - def _save_customer(self, customer): - try: - if not frappe.db.exists( - {"doctype": "Customer", "quickbooks_id": customer["Id"], "company": self.company} - ): - try: - receivable_account = frappe.get_all( - "Account", - filters={ - "account_type": "Receivable", - "account_currency": customer["CurrencyRef"]["value"], - "company": self.company, - }, - )[0]["name"] - except Exception: - receivable_account = None - erpcustomer = frappe.get_doc( - { - "doctype": "Customer", - "quickbooks_id": customer["Id"], - "customer_name": encode_company_abbr(customer["DisplayName"], self.company), - "customer_type": "Individual", - "customer_group": "Commercial", - "default_currency": customer["CurrencyRef"]["value"], - "accounts": [{"company": self.company, "account": receivable_account}], - "territory": "All Territories", - "company": self.company, - } - ).insert() - if "BillAddr" in customer: - self._create_address(erpcustomer, "Customer", customer["BillAddr"], "Billing") - if "ShipAddr" in customer: - self._create_address(erpcustomer, "Customer", customer["ShipAddr"], "Shipping") - except Exception as e: - self._log_error(e, customer) - - def _save_item(self, item): - try: - if not frappe.db.exists( - {"doctype": "Item", "quickbooks_id": item["Id"], "company": self.company} - ): - if item["Type"] in ("Service", "Inventory"): - item_dict = { - "doctype": "Item", - "quickbooks_id": item["Id"], - "item_code": encode_company_abbr(item["Name"], self.company), - "stock_uom": "Unit", - "is_stock_item": 0, - "item_group": "All Item Groups", - "company": self.company, - "item_defaults": [ - {"company": self.company, "default_warehouse": self.default_warehouse} - ], - } - if "ExpenseAccountRef" in item: - expense_account = self._get_account_name_by_id(item["ExpenseAccountRef"]["value"]) - item_dict["item_defaults"][0]["expense_account"] = expense_account - if "IncomeAccountRef" in item: - income_account = self._get_account_name_by_id(item["IncomeAccountRef"]["value"]) - item_dict["item_defaults"][0]["income_account"] = income_account - frappe.get_doc(item_dict).insert() - except Exception as e: - self._log_error(e, item) - - def _allow_fraction_in_unit(self): - frappe.db.set_value("UOM", "Unit", "must_be_whole_number", 0) - - def _save_vendor(self, vendor): - try: - if not frappe.db.exists( - {"doctype": "Supplier", "quickbooks_id": vendor["Id"], "company": self.company} - ): - erpsupplier = frappe.get_doc( - { - "doctype": "Supplier", - "quickbooks_id": vendor["Id"], - "supplier_name": encode_company_abbr(vendor["DisplayName"], self.company), - "supplier_group": "All Supplier Groups", - "company": self.company, - } - ).insert() - if "BillAddr" in vendor: - self._create_address(erpsupplier, "Supplier", vendor["BillAddr"], "Billing") - if "ShipAddr" in vendor: - self._create_address(erpsupplier, "Supplier", vendor["ShipAddr"], "Shipping") - except Exception as e: - self._log_error(e) - - def _save_preference(self, preference): - try: - if preference["SalesFormsPrefs"]["AllowShipping"]: - default_shipping_account_id = preference["SalesFormsPrefs"]["DefaultShippingAccount"] - self.default_shipping_account = self._get_account_name_by_id( - self, default_shipping_account_id - ) - self.save() - except Exception as e: - self._log_error(e, preference) - - def _save_invoice(self, invoice): - # Invoice can be Linked with Another Transactions - # If any of these transactions is a "StatementCharge" or "ReimburseCharge" then in the UI - # item list is populated from the corresponding transaction, these items are not shown in api response - # Also as of now there is no way of fetching the corresponding transaction from api - # We in order to correctly reflect account balance make an equivalent Journal Entry - quickbooks_id = "Invoice - {}".format(invoice["Id"]) - if any( - linked["TxnType"] in ("StatementCharge", "ReimburseCharge") for linked in invoice["LinkedTxn"] - ): - self._save_invoice_as_journal_entry(invoice, quickbooks_id) - else: - self._save_sales_invoice(invoice, quickbooks_id) - - def _save_credit_memo(self, credit_memo): - # Credit Memo is equivalent to a return Sales Invoice - quickbooks_id = "Credit Memo - {}".format(credit_memo["Id"]) - self._save_sales_invoice(credit_memo, quickbooks_id, is_return=True) - - def _save_sales_receipt(self, sales_receipt): - # Sales Receipt is equivalent to a POS Sales Invoice - quickbooks_id = "Sales Receipt - {}".format(sales_receipt["Id"]) - self._save_sales_invoice(sales_receipt, quickbooks_id, is_pos=True) - - def _save_refund_receipt(self, refund_receipt): - # Refund Receipt is equivalent to a return POS Sales Invoice - quickbooks_id = "Refund Receipt - {}".format(refund_receipt["Id"]) - self._save_sales_invoice(refund_receipt, quickbooks_id, is_return=True, is_pos=True) - - def _save_sales_invoice(self, invoice, quickbooks_id, is_return=False, is_pos=False): - try: - if not frappe.db.exists( - {"doctype": "Sales Invoice", "quickbooks_id": quickbooks_id, "company": self.company} - ): - invoice_dict = { - "doctype": "Sales Invoice", - "quickbooks_id": quickbooks_id, - # Quickbooks uses ISO 4217 Code - # of course this gonna come back to bite me - "currency": invoice["CurrencyRef"]["value"], - # Exchange Rate is provided if multicurrency is enabled - # It is not provided if multicurrency is not enabled - "conversion_rate": invoice.get("ExchangeRate", 1), - "posting_date": invoice["TxnDate"], - # QuickBooks doesn't make Due Date a mandatory field this is a hack - "due_date": invoice.get("DueDate", invoice["TxnDate"]), - "customer": frappe.get_all( - "Customer", - filters={ - "quickbooks_id": invoice["CustomerRef"]["value"], - "company": self.company, - }, - )[0]["name"], - "items": self._get_si_items(invoice, is_return=is_return), - "taxes": self._get_taxes(invoice), - # Do not change posting_date upon submission - "set_posting_time": 1, - # QuickBooks doesn't round total - "disable_rounded_total": 1, - "is_return": is_return, - "is_pos": is_pos, - "payments": self._get_invoice_payments(invoice, is_return=is_return, is_pos=is_pos), - "company": self.company, - } - discount = self._get_discount(invoice["Line"]) - if discount: - if invoice["ApplyTaxAfterDiscount"]: - invoice_dict["apply_discount_on"] = "Net Total" - else: - invoice_dict["apply_discount_on"] = "Grand Total" - invoice_dict["discount_amount"] = discount["Amount"] - - invoice_doc = frappe.get_doc(invoice_dict) - invoice_doc.insert() - invoice_doc.submit() - except Exception as e: - self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) - - def _get_si_items(self, invoice, is_return=False): - items = [] - for line in invoice["Line"]: - if line["DetailType"] == "SalesItemLineDetail": - if line["SalesItemLineDetail"]["TaxCodeRef"]["value"] != "TAX": - tax_code = line["SalesItemLineDetail"]["TaxCodeRef"]["value"] - else: - if "TxnTaxCodeRef" in invoice["TxnTaxDetail"]: - tax_code = invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] - else: - tax_code = "NON" - if line["SalesItemLineDetail"]["ItemRef"]["value"] != "SHIPPING_ITEM_ID": - item = frappe.db.get_all( - "Item", - filters={ - "quickbooks_id": line["SalesItemLineDetail"]["ItemRef"]["value"], - "company": self.company, - }, - fields=["name", "stock_uom"], - )[0] - items.append( - { - "item_code": item["name"], - "conversion_factor": 1, - "uom": item["stock_uom"], - "description": line.get( - "Description", line["SalesItemLineDetail"]["ItemRef"]["name"] - ), - "qty": line["SalesItemLineDetail"]["Qty"], - "price_list_rate": line["SalesItemLineDetail"]["UnitPrice"], - "cost_center": self.default_cost_center, - "warehouse": self.default_warehouse, - "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), - } - ) - else: - items.append( - { - "item_name": "Shipping", - "conversion_factor": 1, - "expense_account": self._get_account_name_by_id( - "TaxRate - {}".format(line["SalesItemLineDetail"]["TaxCodeRef"]["value"]) - ), - "uom": "Unit", - "description": "Shipping", - "income_account": self.default_shipping_account, - "qty": 1, - "price_list_rate": line["Amount"], - "cost_center": self.default_cost_center, - "warehouse": self.default_warehouse, - "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), - } - ) - if is_return: - items[-1]["qty"] *= -1 - elif line["DetailType"] == "DescriptionOnly": - items[-1].update( - { - "margin_type": "Percentage", - "margin_rate_or_amount": int(line["Description"].split("%")[0]), - } - ) - return items - - def _get_item_taxes(self, tax_code): - tax_rates = self.tax_rates - item_taxes = {} - if tax_code != "NON": - tax_code = self.tax_codes[tax_code] - for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): - if rate_list_type in tax_code: - for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: - if tax_rate_detail["TaxTypeApplicable"] == "TaxOnAmount": - tax_head = self._get_account_name_by_id( - "TaxRate - {}".format(tax_rate_detail["TaxRateRef"]["value"]) - ) - tax_rate = tax_rates[tax_rate_detail["TaxRateRef"]["value"]] - item_taxes[tax_head] = tax_rate["RateValue"] - return item_taxes - - def _get_invoice_payments(self, invoice, is_return=False, is_pos=False): - if is_pos: - amount = invoice["TotalAmt"] - if is_return: - amount = -amount - return [ - { - "mode_of_payment": "Cash", - "account": self._get_account_name_by_id(invoice["DepositToAccountRef"]["value"]), - "amount": amount, - } - ] - - def _get_discount(self, lines): - for line in lines: - if line["DetailType"] == "DiscountLineDetail" and "Amount" in line["DiscountLineDetail"]: - return line - - def _save_invoice_as_journal_entry(self, invoice, quickbooks_id): - try: - accounts = [] - for line in self.general_ledger["Invoice"][invoice["Id"]]["lines"]: - account_line = {"account": line["account"], "cost_center": self.default_cost_center} - if line["debit"]: - account_line["debit_in_account_currency"] = line["debit"] - elif line["credit"]: - account_line["credit_in_account_currency"] = line["credit"] - if frappe.db.get_value("Account", line["account"], "account_type") == "Receivable": - account_line["party_type"] = "Customer" - account_line["party"] = frappe.get_all( - "Customer", - filters={"quickbooks_id": invoice["CustomerRef"]["value"], "company": self.company}, - )[0]["name"] - - accounts.append(account_line) - - posting_date = invoice["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, [invoice, accounts]) - - def _save_journal_entry(self, journal_entry): - # JournalEntry is equivalent to a Journal Entry - - def _get_je_accounts(lines): - # Converts JounalEntry lines to accounts list - posting_type_field_mapping = { - "Credit": "credit_in_account_currency", - "Debit": "debit_in_account_currency", - } - accounts = [] - for line in lines: - if line["DetailType"] == "JournalEntryLineDetail": - account_name = self._get_account_name_by_id( - line["JournalEntryLineDetail"]["AccountRef"]["value"] - ) - posting_type = line["JournalEntryLineDetail"]["PostingType"] - accounts.append( - { - "account": account_name, - posting_type_field_mapping[posting_type]: line["Amount"], - "cost_center": self.default_cost_center, - } - ) - return accounts - - quickbooks_id = "Journal Entry - {}".format(journal_entry["Id"]) - accounts = _get_je_accounts(journal_entry["Line"]) - posting_date = journal_entry["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - - def __save_journal_entry(self, quickbooks_id, accounts, posting_date): - try: - if not frappe.db.exists( - {"doctype": "Journal Entry", "quickbooks_id": quickbooks_id, "company": self.company} - ): - je = frappe.get_doc( - { - "doctype": "Journal Entry", - "quickbooks_id": quickbooks_id, - "company": self.company, - "posting_date": posting_date, - "accounts": accounts, - "multi_currency": 1, - } - ) - je.insert() - je.submit() - except Exception as e: - self._log_error(e, [accounts, json.loads(je.as_json())]) - - def _save_bill(self, bill): - # Bill is equivalent to a Purchase Invoice - quickbooks_id = "Bill - {}".format(bill["Id"]) - self.__save_purchase_invoice(bill, quickbooks_id) - - def _save_vendor_credit(self, vendor_credit): - # Vendor Credit is equivalent to a return Purchase Invoice - quickbooks_id = "Vendor Credit - {}".format(vendor_credit["Id"]) - self.__save_purchase_invoice(vendor_credit, quickbooks_id, is_return=True) - - def __save_purchase_invoice(self, invoice, quickbooks_id, is_return=False): - try: - if not frappe.db.exists( - {"doctype": "Purchase Invoice", "quickbooks_id": quickbooks_id, "company": self.company} - ): - credit_to_account = self._get_account_name_by_id(invoice["APAccountRef"]["value"]) - invoice_dict = { - "doctype": "Purchase Invoice", - "quickbooks_id": quickbooks_id, - "currency": invoice["CurrencyRef"]["value"], - "conversion_rate": invoice.get("ExchangeRate", 1), - "posting_date": invoice["TxnDate"], - "due_date": invoice.get("DueDate", invoice["TxnDate"]), - "credit_to": credit_to_account, - "supplier": frappe.get_all( - "Supplier", - filters={ - "quickbooks_id": invoice["VendorRef"]["value"], - "company": self.company, - }, - )[0]["name"], - "items": self._get_pi_items(invoice, is_return=is_return), - "taxes": self._get_taxes(invoice), - "set_posting_time": 1, - "disable_rounded_total": 1, - "is_return": is_return, - "udpate_stock": 0, - "company": self.company, - } - invoice_doc = frappe.get_doc(invoice_dict) - invoice_doc.insert() - invoice_doc.submit() - except Exception as e: - self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) - - def _get_pi_items(self, purchase_invoice, is_return=False): - items = [] - for line in purchase_invoice["Line"]: - if line["DetailType"] == "ItemBasedExpenseLineDetail": - if line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": - tax_code = line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] - else: - if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: - tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] - else: - tax_code = "NON" - item = frappe.db.get_all( - "Item", - filters={ - "quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], - "company": self.company, - }, - fields=["name", "stock_uom"], - )[0] - items.append( - { - "item_code": item["name"], - "conversion_factor": 1, - "uom": item["stock_uom"], - "description": line.get( - "Description", line["ItemBasedExpenseLineDetail"]["ItemRef"]["name"] - ), - "qty": line["ItemBasedExpenseLineDetail"]["Qty"], - "price_list_rate": line["ItemBasedExpenseLineDetail"]["UnitPrice"], - "warehouse": self.default_warehouse, - "cost_center": self.default_cost_center, - "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), - } - ) - elif line["DetailType"] == "AccountBasedExpenseLineDetail": - if line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": - tax_code = line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] - else: - if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: - tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] - else: - tax_code = "NON" - items.append( - { - "item_name": line.get( - "Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"] - ), - "conversion_factor": 1, - "expense_account": self._get_account_name_by_id( - line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"] - ), - "uom": "Unit", - "description": line.get( - "Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"] - ), - "qty": 1, - "price_list_rate": line["Amount"], - "warehouse": self.default_warehouse, - "cost_center": self.default_cost_center, - "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), - } - ) - if is_return: - items[-1]["qty"] *= -1 - return items - - def _save_payment(self, payment): - try: - quickbooks_id = "Payment - {}".format(payment["Id"]) - # If DepositToAccountRef is not set on payment that means it actually doesn't affect any accounts - # No need to record such payment - # Such payment record is created QuickBooks Payments API - if "DepositToAccountRef" not in payment: - return - - # A Payment can be linked to multiple transactions - accounts = [] - for line in payment["Line"]: - linked_transaction = line["LinkedTxn"][0] - if linked_transaction["TxnType"] == "Invoice": - si_quickbooks_id = "Invoice - {}".format(linked_transaction["TxnId"]) - # Invoice could have been saved as a Sales Invoice or a Journal Entry - if frappe.db.exists( - { - "doctype": "Sales Invoice", - "quickbooks_id": si_quickbooks_id, - "company": self.company, - } - ): - sales_invoice = frappe.get_all( - "Sales Invoice", - filters={ - "quickbooks_id": si_quickbooks_id, - "company": self.company, - }, - fields=["name", "customer", "debit_to"], - )[0] - reference_type = "Sales Invoice" - reference_name = sales_invoice["name"] - party = sales_invoice["customer"] - party_account = sales_invoice["debit_to"] - - if frappe.db.exists( - { - "doctype": "Journal Entry", - "quickbooks_id": si_quickbooks_id, - "company": self.company, - } - ): - journal_entry = frappe.get_doc( - "Journal Entry", - { - "quickbooks_id": si_quickbooks_id, - "company": self.company, - }, - ) - # Invoice saved as a Journal Entry must have party and party_type set on line containing Receivable Account - customer_account_line = next( - filter(lambda acc: acc.party_type == "Customer", journal_entry.accounts) - ) - - reference_type = "Journal Entry" - reference_name = journal_entry.name - party = customer_account_line.party - party_account = customer_account_line.account - - accounts.append( - { - "party_type": "Customer", - "party": party, - "reference_type": reference_type, - "reference_name": reference_name, - "account": party_account, - "credit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - - deposit_account = self._get_account_name_by_id(payment["DepositToAccountRef"]["value"]) - accounts.append( - { - "account": deposit_account, - "debit_in_account_currency": payment["TotalAmt"], - "cost_center": self.default_cost_center, - } - ) - posting_date = payment["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, [payment, accounts]) - - def _save_bill_payment(self, bill_payment): - try: - quickbooks_id = "BillPayment - {}".format(bill_payment["Id"]) - # A BillPayment can be linked to multiple transactions - accounts = [] - for line in bill_payment["Line"]: - linked_transaction = line["LinkedTxn"][0] - if linked_transaction["TxnType"] == "Bill": - pi_quickbooks_id = "Bill - {}".format(linked_transaction["TxnId"]) - if frappe.db.exists( - { - "doctype": "Purchase Invoice", - "quickbooks_id": pi_quickbooks_id, - "company": self.company, - } - ): - purchase_invoice = frappe.get_all( - "Purchase Invoice", - filters={ - "quickbooks_id": pi_quickbooks_id, - "company": self.company, - }, - fields=["name", "supplier", "credit_to"], - )[0] - reference_type = "Purchase Invoice" - reference_name = purchase_invoice["name"] - party = purchase_invoice["supplier"] - party_account = purchase_invoice["credit_to"] - accounts.append( - { - "party_type": "Supplier", - "party": party, - "reference_type": reference_type, - "reference_name": reference_name, - "account": party_account, - "debit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - - if bill_payment["PayType"] == "Check": - bank_account_id = bill_payment["CheckPayment"]["BankAccountRef"]["value"] - elif bill_payment["PayType"] == "CreditCard": - bank_account_id = bill_payment["CreditCardPayment"]["CCAccountRef"]["value"] - - bank_account = self._get_account_name_by_id(bank_account_id) - accounts.append( - { - "account": bank_account, - "credit_in_account_currency": bill_payment["TotalAmt"], - "cost_center": self.default_cost_center, - } - ) - posting_date = bill_payment["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, [bill_payment, accounts]) - - def _save_purchase(self, purchase): - try: - quickbooks_id = "Purchase - {}".format(purchase["Id"]) - # Credit Bank Account - accounts = [ - { - "account": self._get_account_name_by_id(purchase["AccountRef"]["value"]), - "credit_in_account_currency": purchase["TotalAmt"], - "cost_center": self.default_cost_center, - } - ] - - # Debit Mentioned Accounts - for line in purchase["Line"]: - if line["DetailType"] == "AccountBasedExpenseLineDetail": - account = self._get_account_name_by_id( - line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"] - ) - elif line["DetailType"] == "ItemBasedExpenseLineDetail": - account = ( - frappe.get_doc( - "Item", - { - "quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], - "company": self.company, - }, - ) - .item_defaults[0] - .expense_account - ) - accounts.append( - { - "account": account, - "debit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - - # Debit Tax Accounts - if "TxnTaxDetail" in purchase: - for line in purchase["TxnTaxDetail"]["TaxLine"]: - accounts.append( - { - "account": self._get_account_name_by_id( - "TaxRate - {}".format(line["TaxLineDetail"]["TaxRateRef"]["value"]) - ), - "debit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - - # If purchase["Credit"] is set to be True then it represents a refund - if purchase.get("Credit"): - for account in accounts: - if "debit_in_account_currency" in account: - account["credit_in_account_currency"] = account["debit_in_account_currency"] - del account["debit_in_account_currency"] - else: - account["debit_in_account_currency"] = account["credit_in_account_currency"] - del account["credit_in_account_currency"] - - posting_date = purchase["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, [purchase, accounts]) - - def _save_deposit(self, deposit): - try: - quickbooks_id = "Deposit - {}".format(deposit["Id"]) - # Debit Bank Account - accounts = [ - { - "account": self._get_account_name_by_id(deposit["DepositToAccountRef"]["value"]), - "debit_in_account_currency": deposit["TotalAmt"], - "cost_center": self.default_cost_center, - } - ] - - # Credit Mentioned Accounts - for line in deposit["Line"]: - if "LinkedTxn" in line: - accounts.append( - { - "account": self.undeposited_funds_account, - "credit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - else: - accounts.append( - { - "account": self._get_account_name_by_id( - line["DepositLineDetail"]["AccountRef"]["value"] - ), - "credit_in_account_currency": line["Amount"], - "cost_center": self.default_cost_center, - } - ) - - # Debit Cashback if mentioned - if "CashBack" in deposit: - accounts.append( - { - "account": self._get_account_name_by_id(deposit["CashBack"]["AccountRef"]["value"]), - "debit_in_account_currency": deposit["CashBack"]["Amount"], - "cost_center": self.default_cost_center, - } - ) - - posting_date = deposit["TxnDate"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, [deposit, accounts]) - - def _save_advance_payment(self, advance_payment): - quickbooks_id = "Advance Payment - {}".format(advance_payment["id"]) - self.__save_ledger_entry_as_je(advance_payment, quickbooks_id) - - def _save_tax_payment(self, tax_payment): - quickbooks_id = "Tax Payment - {}".format(tax_payment["id"]) - self.__save_ledger_entry_as_je(tax_payment, quickbooks_id) - - def _save_inventory_qty_adjust(self, inventory_qty_adjust): - quickbooks_id = "Inventory Qty Adjust - {}".format(inventory_qty_adjust["id"]) - self.__save_ledger_entry_as_je(inventory_qty_adjust, quickbooks_id) - - def __save_ledger_entry_as_je(self, ledger_entry, quickbooks_id): - try: - accounts = [] - for line in ledger_entry["lines"]: - account_line = {"account": line["account"], "cost_center": self.default_cost_center} - if line["credit"]: - account_line["credit_in_account_currency"] = line["credit"] - else: - account_line["debit_in_account_currency"] = line["debit"] - accounts.append(account_line) - - posting_date = ledger_entry["date"] - self.__save_journal_entry(quickbooks_id, accounts, posting_date) - except Exception as e: - self._log_error(e, ledger_entry) - - def _get_taxes(self, entry): - taxes = [] - if "TxnTaxDetail" not in entry or "TaxLine" not in entry["TxnTaxDetail"]: - return taxes - for line in entry["TxnTaxDetail"]["TaxLine"]: - tax_rate = line["TaxLineDetail"]["TaxRateRef"]["value"] - account_head = self._get_account_name_by_id(f"TaxRate - {tax_rate}") - tax_type_applicable = self._get_tax_type(tax_rate) - if tax_type_applicable == "TaxOnAmount": - taxes.append( - { - "charge_type": "On Net Total", - "account_head": account_head, - "description": account_head, - "cost_center": self.default_cost_center, - "rate": 0, - } - ) - else: - parent_tax_rate = self._get_parent_tax_rate(tax_rate) - parent_row_id = self._get_parent_row_id(parent_tax_rate, taxes) - taxes.append( - { - "charge_type": "On Previous Row Amount", - "row_id": parent_row_id, - "account_head": account_head, - "description": account_head, - "cost_center": self.default_cost_center, - "rate": line["TaxLineDetail"]["TaxPercent"], - } - ) - return taxes - - def _get_tax_type(self, tax_rate): - for tax_code in self.tax_codes.values(): - for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): - if rate_list_type in tax_code: - for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: - if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: - return tax_rate_detail["TaxTypeApplicable"] - - def _get_parent_tax_rate(self, tax_rate): - parent = None - for tax_code in self.tax_codes.values(): - for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): - if rate_list_type in tax_code: - for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: - if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: - parent = tax_rate_detail["TaxOnTaxOrder"] - if parent: - for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: - if tax_rate_detail["TaxOrder"] == parent: - return tax_rate_detail["TaxRateRef"]["value"] - - def _get_parent_row_id(self, tax_rate, taxes): - tax_account = self._get_account_name_by_id(f"TaxRate - {tax_rate}") - for index, tax in enumerate(taxes): - if tax["account_head"] == tax_account: - return index + 1 - - def _create_address(self, entity, doctype, address, address_type): - try: - if not frappe.db.exists({"doctype": "Address", "quickbooks_id": address["Id"]}): - frappe.get_doc( - { - "doctype": "Address", - "quickbooks_address_id": address["Id"], - "address_title": entity.name, - "address_type": address_type, - "address_line1": address["Line1"], - "city": address["City"], - "links": [{"link_doctype": doctype, "link_name": entity.name}], - } - ).insert() - except Exception as e: - self._log_error(e, address) - - def _get(self, *args, **kwargs): - kwargs["headers"] = { - "Accept": "application/json", - "Authorization": f"Bearer {self.access_token}", - } - response = requests.get(*args, **kwargs) - # HTTP Status code 401 here means that the access_token is expired - # We can refresh tokens and retry - # However limitless recursion does look dangerous - if response.status_code == 401: - self._refresh_tokens() - response = self._get(*args, **kwargs) - return response - - def _get_account_name_by_id(self, quickbooks_id): - return frappe.get_all("Account", filters={"quickbooks_id": quickbooks_id, "company": self.company})[ - 0 - ]["name"] - - def _publish(self, *args, **kwargs): - frappe.publish_realtime("quickbooks_progress_update", *args, **kwargs, user=self.modified_by) - - def _get_unique_account_name(self, quickbooks_name, number=0): - if number: - quickbooks_account_name = f"{quickbooks_name} - {number} - QB" - else: - quickbooks_account_name = f"{quickbooks_name} - QB" - company_encoded_account_name = encode_company_abbr(quickbooks_account_name, self.company) - if frappe.db.exists( - {"doctype": "Account", "name": company_encoded_account_name, "company": self.company} - ): - unique_account_name = self._get_unique_account_name(quickbooks_name, number + 1) - else: - unique_account_name = quickbooks_account_name - return unique_account_name - - def _log_error(self, execption, data=""): - frappe.log_error( - title="QuickBooks Migration Error", - message="\n".join( - [ - "Data", - json.dumps(data, sort_keys=True, indent=4, separators=(",", ": ")), - "Exception", - traceback.format_exc(), - ] - ), - ) - - def set_indicator(self, status): - self.status = status - self.save() - frappe.db.commit() diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py deleted file mode 100644 index 92e79ec8a4a..00000000000 --- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestQuickBooksMigrator(unittest.TestCase): - pass diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 6b2dd77c471..5d13471f541 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1371,6 +1371,64 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None): }, ) + def get_max_op_qty(): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Job Card") + query = ( + frappe.qb.from_(table) + .select(Sum(table.total_completed_qty).as_("qty")) + .where( + (table.docstatus == 1) + & (table.work_order == work_order.name) + & (table.is_corrective_job_card == 0) + ) + .groupby(table.operation) + ) + return min([d.qty for d in query.run(as_dict=True)], default=0) + + def get_utilised_cc(): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Stock Entry") + subquery = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.work_order == work_order.name) + & (table.purpose == "Manufacture") + ) + ) + table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(table) + .select(Sum(table.amount).as_("amount")) + .where(table.parent.isin(subquery) & (table.has_corrective_cost == 1)) + ) + return query.run(as_dict=True)[0].amount or 0 + + if ( + work_order + and work_order.corrective_operation_cost + and cint( + frappe.db.get_single_value( + "Manufacturing Settings", "add_corrective_operation_cost_in_finished_good_valuation" + ) + ) + ): + max_qty = get_max_op_qty() - work_order.produced_qty + remaining_cc = work_order.corrective_operation_cost - get_utilised_cc() + stock_entry.append( + "additional_costs", + { + "expense_account": expense_account, + "description": "Corrective Operation Cost", + "has_corrective_cost": 1, + "amount": remaining_cc / max_qty * flt(stock_entry.fg_completed_qty), + }, + ) + @frappe.whitelist() def get_bom_diff(bom1, bom2): diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index a1b53fb7c4a..90f915d9c24 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -650,7 +650,7 @@ class JobCard(Document): ) ) - if self.get("operation") == d.operation: + if self.get("operation") == d.operation or self.is_corrective_job_card: self.append( "items", { @@ -791,7 +791,7 @@ class JobCard(Document): fields=["total_time_in_mins", "hour_rate"], filters={"is_corrective_job_card": 1, "docstatus": 1, "work_order": self.work_order}, ): - wo.corrective_operation_cost += flt(row.total_time_in_mins) * flt(row.hour_rate) + wo.corrective_operation_cost += (flt(row.total_time_in_mins) / 60) * flt(row.hour_rate) wo.calculate_operating_cost() wo.flags.ignore_validate_update_after_submit = True diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index d6d3775111d..7f456b9881e 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -424,6 +424,92 @@ class TestJobCard(FrappeTestCase): cost_after_cancel = self.work_order.total_operating_cost self.assertEqual(cost_after_cancel, original_cost) + @change_settings( + "Manufacturing Settings", {"add_corrective_operation_cost_in_finished_good_valuation": 1} + ) + def test_if_corrective_jc_ops_cost_is_added_to_manufacture_stock_entry(self): + wo = make_wo_order_test_record( + item="_Test FG Item 2", + qty=10, + transfer_material_against=self.transfer_material_against, + source_warehouse=self.source_warehouse, + ) + self.generate_required_stock(wo) + job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name}) + job_card.update({"for_quantity": 4}) + job_card.append( + "time_logs", + {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 4}, + ) + job_card.submit() + + corrective_action = frappe.get_doc( + doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash() + ).insert() + + corrective_job_card = make_corrective_job_card( + job_card.name, operation=corrective_action.name, for_operation=job_card.operation + ) + corrective_job_card.hour_rate = 100 + corrective_job_card.insert() + corrective_job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=2), + "to_time": add_to_date(now(), hours=2, minutes=30), + "completed_qty": 4, + }, + ) + corrective_job_card.submit() + wo.reload() + + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + + stock_entry = make_stock_entry_for_wo(wo.name, "Manufacture", qty=3) + self.assertEqual(stock_entry.additional_costs[1].amount, 37.5) + frappe.get_doc(stock_entry).submit() + + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + + make_job_card( + wo.name, + [{"name": wo.operations[0].name, "operation": "_Test Operation 1", "qty": 3, "pending_qty": 3}], + ) + workstation = job_card.workstation + job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name}) + job_card.update({"for_quantity": 3}) + job_card.workstation = workstation + job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=3), + "to_time": add_to_date(now(), hours=4), + "completed_qty": 3, + }, + ) + job_card.submit() + + corrective_job_card = make_corrective_job_card( + job_card.name, operation=corrective_action.name, for_operation=job_card.operation + ) + corrective_job_card.hour_rate = 80 + corrective_job_card.insert() + corrective_job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=4), + "to_time": add_to_date(now(), hours=4, minutes=30), + "completed_qty": 3, + }, + ) + corrective_job_card.submit() + wo.reload() + + stock_entry = make_stock_entry_for_wo(wo.name, "Manufacture", qty=4) + self.assertEqual(stock_entry.additional_costs[1].amount, 52.5) + def test_job_card_statuses(self): def assertStatus(status): jc.set_status() diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 515520b4810..332a86979c2 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -348,8 +348,9 @@ class WorkOrder(Document): if flt(self.material_transferred_for_manufacturing) > 0: status = "In Process" - total_qty = flt(flt(self.produced_qty) + flt(self.process_loss_qty), self.precision("qty")) - if flt(total_qty) >= flt(self.qty): + precision = frappe.get_precision("Work Order", "produced_qty") + total_qty = flt(self.produced_qty, precision) + flt(self.process_loss_qty, precision) + if flt(total_qty, precision) >= flt(self.qty, precision): status = "Completed" else: status = "Cancelled" diff --git a/erpnext/projects/doctype/project/test_records.json b/erpnext/projects/doctype/project/test_records.json index 567f359b50d..1482336631b 100644 --- a/erpnext/projects/doctype/project/test_records.json +++ b/erpnext/projects/doctype/project/test_records.json @@ -1,6 +1,7 @@ [ { "project_name": "_Test Project", - "status": "Open" + "status": "Open", + "company": "_Test Company" } ] \ No newline at end of file diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index ee78e493db6..875e6980e1e 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -822,7 +822,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } - set_total_amount_to_default_mop() { + async set_total_amount_to_default_mop() { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; @@ -844,6 +844,45 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { ); } + /* + During returns, if an user select mode of payment other than + default mode of payment, it should retain the user selection + instead resetting it to default mode of payment. + */ + + let payment_amount = 0; + this.frm.doc.payments.forEach(payment => { + payment_amount += payment.amount + }); + + if (payment_amount == total_amount_to_pay) { + return; + } + + /* + For partial return, if the payment was made using single mode of payment + it should set the return to that mode of payment only. + */ + + let return_against_mop = await frappe.call({ + method: 'erpnext.controllers.sales_and_purchase_return.get_payment_data', + args: { + invoice: this.frm.doc.return_against + } + }); + + if (return_against_mop.message.length === 1) { + this.frm.doc.payments.forEach(payment => { + if (payment.mode_of_payment == return_against_mop.message[0].mode_of_payment) { + payment.amount = total_amount_to_pay; + } else { + payment.amount = 0; + } + }); + this.frm.refresh_fields(); + return; + } + this.frm.doc.payments.find(payment => { if (payment.default) { payment.amount = total_amount_to_pay; diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index d63461c23e0..86c9ef46c78 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1658,7 +1658,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, callback: function(r) { if (!r.exc && r.message) { - me.remove_pricing_rule(r.message, removed_pricing_rule); + me.remove_pricing_rule(r.message, removed_pricing_rule, item.name); me.calculate_taxes_and_totals(); if(me.frm.doc.apply_discount_on) me.frm.trigger("apply_discount_on"); } @@ -1751,7 +1751,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "serial_no": d.serial_no, "batch_no": d.batch_no, "price_list_rate": d.price_list_rate, - "conversion_factor": d.conversion_factor || 1.0 + "conversion_factor": d.conversion_factor || 1.0, + "discount_percentage" : d.discount_percentage, + "discount_amount" : d.discount_amount, }); // if doctype is Quotation Item / Sales Order Iten then add Margin Type and rate in item_list @@ -1935,7 +1937,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } - remove_pricing_rule(item, removed_pricing_rule) { + remove_pricing_rule(item, removed_pricing_rule, row_name) { let me = this; const fields = ["discount_percentage", "discount_amount", "margin_rate_or_amount", "rate_with_margin"]; @@ -1974,6 +1976,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe me.trigger_price_list_rate(); } + else if(!item.is_free_item && row_name){ + me.frm.doc.items.forEach(d => { + if (d.name != row_name) return; + + Object.assign(d, item); + }); + } } trigger_price_list_rate() { diff --git a/erpnext/public/js/queries.js b/erpnext/public/js/queries.js index 46958092199..63651ec8759 100644 --- a/erpnext/public/js/queries.js +++ b/erpnext/public/js/queries.js @@ -28,9 +28,13 @@ $.extend(erpnext.queries, { customer_filter: function (doc) { if (!doc.customer) { - frappe.throw( - __("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "customer", doc.name))]) - ); + cur_frm.scroll_to_field("customer"); + frappe.show_alert({ + message: __("Please set {0} first.", [ + __(frappe.meta.get_label(doc.doctype, "customer", doc.name)), + ]), + indicator: "orange", + }); } return { filters: { customer: doc.customer } }; @@ -39,11 +43,13 @@ $.extend(erpnext.queries, { contact_query: function (doc) { if (frappe.dynamic_link) { if (!doc[frappe.dynamic_link.fieldname]) { - frappe.throw( - __("Please set {0}", [ + cur_frm.scroll_to_field(frappe.dynamic_link.fieldname); + frappe.show_alert({ + message: __("Please set {0} first.", [ __(frappe.meta.get_label(doc.doctype, frappe.dynamic_link.fieldname, doc.name)), - ]) - ); + ]), + indicator: "orange", + }); } return { @@ -70,11 +76,13 @@ $.extend(erpnext.queries, { address_query: function (doc) { if (frappe.dynamic_link) { if (!doc[frappe.dynamic_link.fieldname]) { - frappe.throw( - __("Please set {0}", [ + cur_frm.scroll_to_field(frappe.dynamic_link.fieldname); + frappe.show_alert({ + message: __("Please set {0} first.", [ __(frappe.meta.get_label(doc.doctype, frappe.dynamic_link.fieldname, doc.name)), - ]) - ); + ]), + indicator: "orange", + }); } return { @@ -89,7 +97,13 @@ $.extend(erpnext.queries, { company_address_query: function (doc) { if (!doc.company) { - frappe.throw(__("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "company", doc.name))])); + cur_frm.scroll_to_field("company"); + frappe.show_alert({ + message: __("Please set {0} first.", [ + __(frappe.meta.get_label(doc.doctype, "company", doc.name)), + ]), + indicator: "orange", + }); } return { @@ -110,9 +124,13 @@ $.extend(erpnext.queries, { supplier_filter: function (doc) { if (!doc.supplier) { - frappe.throw( - __("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "supplier", doc.name))]) - ); + cur_frm.scroll_to_field("supplier"); + frappe.show_alert({ + message: __("Please set {0} first.", [ + __(frappe.meta.get_label(doc.doctype, "supplier", doc.name)), + ]), + indicator: "orange", + }); } return { filters: { supplier: doc.supplier } }; @@ -120,9 +138,13 @@ $.extend(erpnext.queries, { lead_filter: function (doc) { if (!doc.lead) { - frappe.throw( - __("Please specify a {0}", [__(frappe.meta.get_label(doc.doctype, "lead", doc.name))]) - ); + cur_frm.scroll_to_field("lead"); + frappe.show_alert({ + message: __("Please specify a {0} first.", [ + __(frappe.meta.get_label(doc.doctype, "lead", doc.name)), + ]), + indicator: "orange", + }); } return { filters: { lead: doc.lead } }; diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 7c02fefc0f9..a000cdee7cc 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -654,6 +654,62 @@ erpnext.utils.update_child_items = function (opts) { filters: filters, }; }, + onchange: function () { + const me = this; + + frm.call({ + method: "erpnext.stock.get_item_details.get_item_details", + args: { + doc: frm.doc, + ctx: { + item_code: this.value, + set_warehouse: frm.doc.set_warehouse, + customer: frm.doc.customer || frm.doc.party_name, + quotation_to: frm.doc.quotation_to, + supplier: frm.doc.supplier, + currency: frm.doc.currency, + is_internal_supplier: frm.doc.is_internal_supplier, + is_internal_customer: frm.doc.is_internal_customer, + conversion_rate: frm.doc.conversion_rate, + price_list: frm.doc.selling_price_list || frm.doc.buying_price_list, + price_list_currency: frm.doc.price_list_currency, + plc_conversion_rate: frm.doc.plc_conversion_rate, + company: frm.doc.company, + order_type: frm.doc.order_type, + is_pos: cint(frm.doc.is_pos), + is_return: cint(frm.doc.is_return), + is_subcontracted: frm.doc.is_subcontracted, + ignore_pricing_rule: frm.doc.ignore_pricing_rule, + doctype: frm.doc.doctype, + name: frm.doc.name, + qty: me.doc.qty || 1, + uom: me.doc.uom, + pos_profile: cint(frm.doc.is_pos) ? frm.doc.pos_profile : "", + tax_category: frm.doc.tax_category, + child_doctype: frm.doc.doctype + " Item", + is_old_subcontracting_flow: frm.doc.is_old_subcontracting_flow, + }, + }, + callback: function (r) { + if (r.message) { + const { qty, price_list_rate: rate, uom, conversion_factor } = r.message; + + const row = dialog.fields_dict.trans_items.df.data.find( + (doc) => doc.idx == me.doc.idx + ); + if (row) { + Object.assign(row, { + conversion_factor: me.doc.conversion_factor || conversion_factor, + uom: me.doc.uom || uom, + qty: me.doc.qty || qty, + rate: me.doc.rate || rate, + }); + dialog.fields_dict.trans_items.grid.refresh(); + } + } + }, + }); + }, }, { fieldtype: "Link", diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index ae744b9cba3..b795c3fe0bc 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -12,13 +12,17 @@ frappe.listview_settings["Quotation"] = { }; } - listview.page.add_action_item(__("Sales Order"), () => { - erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order"); - }); + if (frappe.model.can_create("Sales Order")) { + listview.page.add_action_item(__("Sales Order"), () => { + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order"); + }); + } - listview.page.add_action_item(__("Sales Invoice"), () => { - erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice"); - }); + if (frappe.model.can_create("Sales Invoice")) { + listview.page.add_action_item(__("Sales Invoice"), () => { + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice"); + }); + } }, get_indicator: function (doc) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 374c37f99bf..effc3f3894d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -872,7 +872,11 @@ def make_material_request(source_name, target_doc=None): }, "Sales Order Item": { "doctype": "Material Request Item", - "field_map": {"name": "sales_order_item", "parent": "sales_order"}, + "field_map": { + "name": "sales_order_item", + "parent": "sales_order", + "delivery_date": "required_by", + }, "condition": lambda item: not frappe.db.exists( "Product Bundle", {"name": item.item_code, "disabled": 0} ) @@ -1357,7 +1361,8 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t "postprocess": update_item, "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier - and doc.item_code in items_to_map, + and doc.item_code in items_to_map + and doc.delivered_by_supplier == 1, }, }, target_doc, @@ -1501,6 +1506,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): ) set_delivery_date(doc.items, source_name) + doc.set_onload("load_after_mapping", False) return doc diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js index 46d115a1713..c9bd4fc0f9d 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_list.js +++ b/erpnext/selling/doctype/sales_order/sales_order_list.js @@ -60,16 +60,22 @@ frappe.listview_settings["Sales Order"] = { listview.call_for_selected_items(method, { status: "Submitted" }); }); - listview.page.add_action_item(__("Sales Invoice"), () => { - erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice"); - }); + if (frappe.model.can_create("Sales Invoice")) { + listview.page.add_action_item(__("Sales Invoice"), () => { + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice"); + }); + } - listview.page.add_action_item(__("Delivery Note"), () => { - erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note"); - }); + if (frappe.model.can_create("Delivery Note")) { + listview.page.add_action_item(__("Delivery Note"), () => { + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note"); + }); + } - listview.page.add_action_item(__("Advance Payment"), () => { - erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Payment Entry"); - }); + if (frappe.model.can_create("Payment Entry")) { + listview.page.add_action_item(__("Advance Payment"), () => { + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Payment Entry"); + }); + } }, }; diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index dc7e992f654..9da530081e7 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -155,7 +155,7 @@ erpnext.PointOfSale.Controller = class { this.page.set_title_sub( ` - Opened at ${moment(this.pos_opening_time).format("Do MMMM, h:mma")} + Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)} ` ); @@ -165,6 +165,7 @@ erpnext.PointOfSale.Controller = class { this.prepare_dom(); this.prepare_components(); this.prepare_menu(); + this.prepare_fullscreen_btn(); this.make_new_invoice(); } @@ -200,6 +201,39 @@ erpnext.PointOfSale.Controller = class { this.page.add_menu_item(__("Close the POS"), this.close_pos.bind(this), false, "Shift+Ctrl+C"); } + prepare_fullscreen_btn() { + this.page.page_actions.find(".custom-actions").empty(); + + this.page.add_button(__("Full Screen"), null, { btn_class: "btn-default fullscreen-btn" }); + + this.bind_fullscreen_events(); + } + + bind_fullscreen_events() { + this.$fullscreen_btn = this.page.page_actions.find(".fullscreen-btn"); + + this.$fullscreen_btn.on("click", function () { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } + }); + + $(document).on("fullscreenchange", this.handle_fullscreen_change_event.bind(this)); + } + + handle_fullscreen_change_event() { + let enable_fullscreen_label = __("Full Screen"); + let exit_fullscreen_label = __("Exit Full Screen"); + + if (document.fullscreenElement) { + this.$fullscreen_btn[0].innerText = exit_fullscreen_label; + } else { + this.$fullscreen_btn[0].innerText = enable_fullscreen_label; + } + } + open_form_view() { frappe.model.sync(this.frm.doc); frappe.set_route("Form", this.frm.doc.doctype, this.frm.doc.name); diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 28cb1aef339..9de6dbbd429 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -988,8 +988,8 @@ erpnext.PointOfSale.ItemCart = class { .html(`${__("Last transacted")} ${__(elapsed_time)}`); res.forEach((invoice) => { - const posting_datetime = moment(invoice.posting_date + " " + invoice.posting_time).format( - "Do MMMM, h:mma" + const posting_datetime = frappe.datetime.str_to_user( + invoice.posting_date + " " + invoice.posting_time ); let indicator_color = { Paid: "green", diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_list.js b/erpnext/selling/page/point_of_sale/pos_past_order_list.js index c450d8a109a..dda44f25299 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_list.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_list.js @@ -96,8 +96,8 @@ erpnext.PointOfSale.PastOrderList = class { } get_invoice_html(invoice) { - const posting_datetime = moment(invoice.posting_date + " " + invoice.posting_time).format( - "Do MMMM, h:mma" + const posting_datetime = frappe.datetime.str_to_user( + invoice.posting_date + " " + invoice.posting_time ); return `
@@ -110,7 +110,7 @@ erpnext.PointOfSale.PastOrderList = class {
-
${format_currency(invoice.grand_total, invoice.currency, 0) || 0}
+
${format_currency(invoice.grand_total, invoice.currency) || 0}
${posting_datetime}
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index ed6e6e02dcc..df44fdb04e8 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -1,7 +1,8 @@ erpnext.PointOfSale.PastOrderSummary = class { - constructor({ wrapper, events }) { + constructor({ wrapper, events, pos_profile }) { this.wrapper = wrapper; this.events = events; + this.pos_profile = pos_profile; this.init_component(); } @@ -355,6 +356,8 @@ erpnext.PointOfSale.PastOrderSummary = class { const condition_btns_map = this.get_condition_btn_map(after_submission); this.add_summary_btns(condition_btns_map); + + this.print_receipt_on_order_complete(); } attach_document_info(doc) { @@ -421,4 +424,16 @@ erpnext.PointOfSale.PastOrderSummary = class { toggle_component(show) { show ? this.$component.css("display", "flex") : this.$component.css("display", "none"); } + + async print_receipt_on_order_complete() { + const res = await frappe.db.get_value( + "POS Profile", + this.pos_profile, + "print_receipt_on_order_complete" + ); + + if (res.message.print_receipt_on_order_complete) { + this.print_receipt(); + } + } }; diff --git a/erpnext/selling/report/address_and_contacts/address_and_contacts.py b/erpnext/selling/report/address_and_contacts/address_and_contacts.py index b8ab89a4fed..5d0e706930f 100644 --- a/erpnext/selling/report/address_and_contacts/address_and_contacts.py +++ b/erpnext/selling/report/address_and_contacts/address_and_contacts.py @@ -31,9 +31,9 @@ def get_columns(filters): f"{frappe.unscrub(str(party_type_value))}::150", "Address Line 1", "Address Line 2", + "Postal Code", "City", "State", - "Postal Code", "Country", "Is Primary Address:Check", "First Name", diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 2fa5531d602..31568fe50dc 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -41,6 +41,7 @@ class Employee(NestedSet): self.validate_email() self.validate_status() self.validate_reports_to() + self.set_preferred_email() self.validate_preferred_email() if self.user_id: @@ -160,9 +161,7 @@ class Employee(NestedSet): def set_preferred_email(self): preferred_email_field = frappe.scrub(self.prefered_contact_email) - if preferred_email_field: - preferred_email = self.get(preferred_email_field) - self.prefered_email = preferred_email + self.prefered_email = self.get(preferred_email_field) if preferred_email_field else None def validate_status(self): if self.status == "Left": diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 7611d751fdd..38e820c4c4f 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -245,8 +245,9 @@ class DeprecatedBatchNoValuation: last_sle = self.get_last_sle_for_non_batch() for d in batch_data: - self.non_batchwise_balance_value[d.batch_no] += flt(last_sle.stock_value) - self.non_batchwise_balance_qty[d.batch_no] += flt(last_sle.qty_after_transaction) + if self.available_qty.get(d.batch_no): + self.non_batchwise_balance_value[d.batch_no] += flt(last_sle.stock_value) + self.non_batchwise_balance_qty[d.batch_no] += flt(last_sle.qty_after_transaction) def get_last_sle_for_non_batch(self): from erpnext.stock.utils import get_combine_datetime @@ -292,6 +293,58 @@ class DeprecatedBatchNoValuation: data = query.run(as_dict=True) return data[0] if data else {} + @deprecated + def get_last_sle_for_sabb_no_batchwise_valuation(self): + sabb = frappe.qb.DocType("Serial and Batch Bundle") + sabb_entry = frappe.qb.DocType("Serial and Batch Entry") + batch = frappe.qb.DocType("Batch") + + posting_datetime = CombineDatetime(self.sle.posting_date, self.sle.posting_time) + timestamp_condition = CombineDatetime(sabb.posting_date, sabb.posting_time) < posting_datetime + + if self.sle.creation: + timestamp_condition |= ( + CombineDatetime(sabb.posting_date, sabb.posting_time) == posting_datetime + ) & (sabb.creation < self.sle.creation) + + query = ( + frappe.qb.from_(sabb) + .inner_join(sabb_entry) + .on(sabb.name == sabb_entry.parent) + .inner_join(batch) + .on(sabb_entry.batch_no == batch.name) + .select(sabb.name) + .where( + (sabb.item_code == self.sle.item_code) + & (sabb.warehouse == self.sle.warehouse) + & (sabb_entry.batch_no.isnotnull()) + & (batch.use_batchwise_valuation == 0) + & (sabb.is_cancelled == 0) + & (sabb.docstatus == 1) + ) + .where(timestamp_condition) + .orderby(sabb.posting_date, order=Order.desc) + .orderby(sabb.posting_time, order=Order.desc) + .orderby(sabb.creation, order=Order.desc) + .limit(1) + ) + + if self.sle.voucher_detail_no: + query = query.where(sabb.voucher_detail_no != self.sle.voucher_detail_no) + + data = query.run(as_dict=True) + if not data: + return {} + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"serial_and_batch_bundle": data[0].name}, + ["stock_value", "qty_after_transaction"], + as_dict=1, + ) + + return sle if sle else {} + @deprecated def set_balance_value_from_bundle(self) -> None: bundle = frappe.qb.DocType("Serial and Batch Bundle") @@ -338,7 +391,14 @@ class DeprecatedBatchNoValuation: query = query.where(bundle.voucher_type != "Pick List") - for d in query.run(as_dict=True): - self.non_batchwise_balance_value[d.batch_no] += flt(d.batch_value) - self.non_batchwise_balance_qty[d.batch_no] += flt(d.batch_qty) + batch_data = query.run(as_dict=True) + for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) + + last_sle = self.get_last_sle_for_sabb_no_batchwise_valuation() + if not last_sle: + return + + for batch_no in self.available_qty: + self.non_batchwise_balance_value[batch_no] = flt(last_sle.stock_value) + self.non_batchwise_balance_qty[batch_no] = flt(last_sle.qty_after_transaction) diff --git a/erpnext/stock/doctype/batch/batch.js b/erpnext/stock/doctype/batch/batch.js index 77e4e560acf..a485f849639 100644 --- a/erpnext/stock/doctype/batch/batch.js +++ b/erpnext/stock/doctype/batch/batch.js @@ -54,7 +54,12 @@ frappe.ui.form.on("Batch", { frappe.call({ method: "erpnext.stock.doctype.batch.batch.get_batch_qty", - args: { batch_no: frm.doc.name, item_code: frm.doc.item, for_stock_levels: for_stock_levels }, + args: { + batch_no: frm.doc.name, + item_code: frm.doc.item, + for_stock_levels: for_stock_levels, + consider_negative_batches: 1, + }, callback: (r) => { if (!r.message) { return; @@ -71,7 +76,7 @@ frappe.ui.form.on("Batch", { // show (r.message || []).forEach(function (d) { - if (d.qty > 0) { + if (d.qty != 0) { $(`
${d.warehouse}
${d.qty}
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 3882e5b2424..1e1e6c8ee26 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -218,6 +218,7 @@ def get_batch_qty( posting_time=None, ignore_voucher_nos=None, for_stock_levels=False, + consider_negative_batches=False, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -243,6 +244,7 @@ def get_batch_qty( "batch_no": batch_no, "ignore_voucher_nos": ignore_voucher_nos, "for_stock_levels": for_stock_levels, + "consider_negative_batches": consider_negative_batches, } ) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index c6b98c4134c..dd09f6cfcf5 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -63,16 +63,20 @@ frappe.listview_settings["Delivery Note"] = { } }; - // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); + if (frappe.model.can_create("Delivery Trip")) { + doclist.page.add_action_item(__("Create Delivery Trip"), action); + } - doclist.page.add_action_item(__("Create Delivery Trip"), action); + if (frappe.model.can_create("Sales Invoice")) { + doclist.page.add_action_item(__("Sales Invoice"), () => { + erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Sales Invoice"); + }); + } - doclist.page.add_action_item(__("Sales Invoice"), () => { - erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Sales Invoice"); - }); - - doclist.page.add_action_item(__("Packaging Slip From Delivery Note"), () => { - erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Packing Slip"); - }); + if (frappe.model.can_create("Packing Slip")) { + doclist.page.add_action_item(__("Packaging Slip From Delivery Note"), () => { + erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Packing Slip"); + }); + } }, }; diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index 9c59c13ac07..898848ebf42 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -11,7 +11,8 @@ "description", "col_break3", "amount", - "base_amount" + "base_amount", + "has_corrective_cost" ], "fields": [ { @@ -62,12 +63,19 @@ "label": "Amount (Company Currency)", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_corrective_cost", + "fieldtype": "Check", + "label": "Has Corrective Cost", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-17 13:57:10.807980", + "modified": "2025-01-20 12:22:03.455762", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py index 8509cb71d85..a3f7f037d60 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py @@ -20,6 +20,7 @@ class LandedCostTaxesandCharges(Document): description: DF.SmallText exchange_rate: DF.Float expense_account: DF.Link | None + has_corrective_cost: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js index 21f0784df75..b35b3aa0f19 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js @@ -6,6 +6,10 @@ frappe.ui.form.on("Serial and Batch Bundle", { frm.trigger("set_queries"); }, + before_submit(frm) { + frappe.throw(__("User cannot submitted the Serial and Batch Bundle manually")); + }, + refresh(frm) { frm.trigger("toggle_fields"); frm.trigger("prepare_serial_batch_prompt"); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index df6a61d335b..635fd1a1fcf 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -507,18 +507,6 @@ frappe.ui.form.on("Stock Entry", { }); }, - company: function (frm) { - if (frm.doc.company) { - var company_doc = frappe.get_doc(":Company", frm.doc.company); - if (company_doc.default_letter_head) { - frm.set_value("letter_head", company_doc.default_letter_head); - } - frm.trigger("toggle_display_account_head"); - - erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); - } - }, - make_retention_stock_entry: function (frm) { frappe.call({ method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse", @@ -918,7 +906,12 @@ frappe.ui.form.on("Stock Entry Detail", { var d = locals[cdt][cdn]; $.each(r.message, function (k, v) { if (v) { - frappe.model.set_value(cdt, cdn, k, v); // qty and it's subsequent fields weren't triggered + // set_value trigger barcode function and barcode set qty to 1 in stock_controller.js, to avoid this set value manually instead of set value. + if (k != "barcode") { + frappe.model.set_value(cdt, cdn, k, v); // qty and it's subsequent fields weren't triggered + } else { + d.barcode = v; + } } }); refresh_field("items"); @@ -1060,11 +1053,9 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle onload_post_render() { var me = this; - this.set_default_account(function () { - if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) { - me.frm.trigger("company"); - } - }); + if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) { + me.company(); + } this.frm.get_field("items").grid.set_multiple_add("item_code", "qty"); } @@ -1143,26 +1134,40 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle this.clean_up(); } - set_default_account(callback) { + company() { + if (this.frm.doc.company) { + var company_doc = frappe.get_doc(":Company", this.frm.doc.company); + if (company_doc.default_letter_head) { + this.frm.set_value("letter_head", company_doc.default_letter_head); + } + this.frm.trigger("toggle_display_account_head"); + + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + if (this.frm.doc.company && erpnext.is_perpetual_inventory_enabled(this.frm.doc.company)) + this.set_default_account("stock_adjustment_account", "expense_account"); + this.set_default_account("cost_center", "cost_center"); + + this.frm.refresh_fields("items"); + } + } + + set_default_account(company_fieldname, fieldname) { var me = this; - if (this.frm.doc.company && erpnext.is_perpetual_inventory_enabled(this.frm.doc.company)) { - return this.frm.call({ - method: "erpnext.accounts.utils.get_company_default", - args: { - fieldname: "stock_adjustment_account", - company: this.frm.doc.company, - }, - callback: function (r) { - if (!r.exc) { - $.each(me.frm.doc.items || [], function (i, d) { - if (!d.expense_account) d.expense_account = r.message; - }); - if (callback) callback(); - } - }, - }); - } + return this.frm.call({ + method: "erpnext.accounts.utils.get_company_default", + args: { + fieldname: company_fieldname, + company: this.frm.doc.company, + }, + callback: function (r) { + if (!r.exc) { + $.each(me.frm.doc.items || [], function (i, d) { + d[fieldname] = r.message; + }); + } + }, + }); } clean_up() { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index c95de8b48a2..eec30f69f36 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2868,17 +2868,6 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): if bom.quantity: operating_cost_per_unit = flt(bom.operating_cost) / flt(bom.quantity) - if ( - work_order - and work_order.produced_qty - and cint( - frappe.db.get_single_value( - "Manufacturing Settings", "add_corrective_operation_cost_in_finished_good_valuation" - ) - ) - ): - operating_cost_per_unit += flt(work_order.corrective_operation_cost) / flt(work_order.produced_qty) - return operating_cost_per_unit @@ -3244,12 +3233,13 @@ def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=N } ) + precision = frappe.get_precision("Stock Entry Detail", "qty") if row.serial_nos and row.batches_to_be_consume: doc.has_serial_no = 1 doc.has_batch_no = 1 batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row) for batch_no, qty in row.batches_to_be_consume.items(): - while qty > 0: + while flt(qty, precision) > 0: qty -= 1 doc.append( "entries", @@ -3270,8 +3260,9 @@ def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=N precision = frappe.get_precision("Serial and Batch Entry", "qty") doc.has_batch_no = 1 for batch_no, qty in row.batches_to_be_consume.items(): - qty = flt(qty, precision) - doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1}) + if flt(qty, precision) > 0: + qty = flt(qty, precision) + doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1}) if not doc.entries: return None diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 9e23270ca71..85c74480e7d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -139,8 +139,8 @@ class StockReconciliation(StockController): "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, - "qty": row.current_qty, - "type_of_transaction": "Outward", + "qty": row.current_qty * -1, + "type_of_transaction": "Outward" if row.current_qty > 0 else "Inward", "company": self.company, "is_rejected": 0, "serial_nos": get_serial_nos(row.current_serial_no) @@ -1367,6 +1367,7 @@ def get_stock_balance_for( posting_date=posting_date, posting_time=posting_time, for_stock_levels=True, + consider_negative_batches=True, ) or 0 ) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index a3673063a48..48a27a25962 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1330,6 +1330,84 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(stock_value_difference, 1500.00 * -1) + def test_stock_reco_for_negative_batch(self): + from erpnext.stock.doctype.batch.batch import get_batch_qty + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test Item For Negative Batch", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-NB-.###", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + se = make_stock_entry( + posting_date="2024-11-01", + posting_time="11:00", + item_code=item_code, + target=warehouse, + qty=10, + basic_rate=100, + ) + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + se = make_stock_entry( + posting_date="2024-11-01", + posting_time="11:00", + item_code=item_code, + source=warehouse, + qty=10, + basic_rate=100, + use_serial_batch_fields=1, + batch_no=batch_no, + ) + + sles = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": se.name, "is_cancelled": 0}, + ) + + # intentionally setting negative qty + doc = frappe.get_doc("Stock Ledger Entry", sles[0].name) + doc.db_set( + { + "actual_qty": -20, + "qty_after_transaction": -10, + } + ) + + sabb_doc = frappe.get_doc("Serial and Batch Bundle", doc.serial_and_batch_bundle) + for row in sabb_doc.entries: + row.db_set("qty", -20) + + batch_qty = get_batch_qty(batch_no, warehouse, item_code, consider_negative_batches=True) + self.assertEqual(batch_qty, -10) + + sr = create_stock_reconciliation( + posting_date="2024-11-02", + posting_time="11:00", + item_code=item_code, + warehouse=warehouse, + use_serial_batch_fields=1, + batch_no=batch_no, + qty=0, + rate=100, + do_not_submit=True, + ) + + self.assertEqual(sr.items[0].current_qty, -10) + sr.submit() + sr.reload() + + self.assertTrue(sr.items[0].current_serial_and_batch_bundle) + self.assertFalse(sr.items[0].serial_and_batch_bundle) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index 1d86634fd95..0d68caa7e09 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -41,8 +41,14 @@ frappe.query_reports["Stock Balance"] = { width: "80", options: "Item", get_query: function () { + let item_group = frappe.query_report.get_filter_value("item_group"); + return { query: "erpnext.controllers.queries.item_query", + filters: { + ...(item_group && { item_group }), + is_stock_item: 1, + }, }; }, }, diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 85adb0348d9..f4d862b583c 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -418,7 +418,13 @@ class SerialBatchBundle: batches = frappe._dict({self.sle.batch_no: self.sle.actual_qty}) batches_qty = get_available_batches( - frappe._dict({"item_code": self.item_code, "batch_no": list(batches.keys())}) + frappe._dict( + { + "item_code": self.item_code, + "batch_no": list(batches.keys()), + "consider_negative_batches": 1, + } + ) ) for batch_no in batches: diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index ee5893eb826..6369562a62d 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -58,7 +58,10 @@ def get_stock_value_from_bin(warehouse=None, item_code=None): def get_stock_value_on( - warehouses: list | str | None = None, posting_date: str | None = None, item_code: str | None = None + warehouses: list | str | None = None, + posting_date: str | None = None, + item_code: str | None = None, + company: str | None = None, ) -> float: if not posting_date: posting_date = nowdate() @@ -84,6 +87,9 @@ def get_stock_value_on( if item_code: query = query.where(sle.item_code == item_code) + if company: + query = query.where(sle.company == company) + return query.run(as_list=True)[0][0] diff --git a/erpnext/translations/bn.csv b/erpnext/translations/bn.csv index 7bfbd07be36..f77199d085c 100644 --- a/erpnext/translations/bn.csv +++ b/erpnext/translations/bn.csv @@ -8743,3 +8743,12 @@ WhatsApp,হোয়াটসঅ্যাপ, Make a call,ফোন করুন, Approve,অনুমোদন করা, Reject,প্রত্যাখ্যান, +Signature,স্বাক্ষর, +Signature is mandatory,স্বাক্ষর আবশ্যক, +Signature is not mandatory,স্বাক্ষর আবশ্যক নয়, +Authorised Signature,অনুমোদিত স্বাক্ষর, +Billing Month,বিলিং মাস, +Name of Client,ক্লায়েন্টের নাম, +Client Name,ক্লায়েন্টের নাম, +Client,ক্লায়েন্ট, +BD,বিডি, diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index 7ba687941c9..51447e0591b 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -8,6 +8,9 @@ from frappe.utils import get_link_to_form, today @frappe.whitelist() def transaction_processing(data, from_doctype, to_doctype): + frappe.has_permission(from_doctype, "read", throw=True) + frappe.has_permission(to_doctype, "create", throw=True) + if isinstance(data, str): deserialized_data = json.loads(data) else: diff --git a/erpnext/utilities/web_form/addresses/addresses.json b/erpnext/utilities/web_form/addresses/addresses.json index 4e2d8e36c2c..38b73926985 100644 --- a/erpnext/utilities/web_form/addresses/addresses.json +++ b/erpnext/utilities/web_form/addresses/addresses.json @@ -84,6 +84,18 @@ "reqd": 0, "show_in_filter": 0 }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "pincode", + "fieldtype": "Data", + "hidden": 0, + "label": "Postal Code", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, { "allow_read_on_all_link_options": 0, "fieldname": "city", @@ -108,18 +120,7 @@ "reqd": 0, "show_in_filter": 0 }, - { - "allow_read_on_all_link_options": 0, - "fieldname": "pincode", - "fieldtype": "Data", - "hidden": 0, - "label": "Postal Code", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, - "show_in_filter": 0 - }, + { "allow_read_on_all_link_options": 1, "fieldname": "country",