Compare commits

...

128 Commits

Author SHA1 Message Date
mergify[bot]
90570cc6c6 Merge of #56066 2026-06-19 12:35:45 +00:00
rohitwaghchaure
8479a8b4d3 Merge pull request #56102 from rohitwaghchaure/feat-allocate-full-amount-to-stock-items
feat: allocate full actual charge to stock items only (e.g. Freight)
2026-06-19 18:05:18 +05:30
Mihir Kandoi
9a612d0164 Merge pull request #56153 from mihir-kandoi/pg-selling
refactor(postgres): port Selling module queries to the query builder
2026-06-19 17:09:29 +05:30
rohitwaghchaure
3a6b32bcf9 Merge pull request #56032 from rohitwaghchaure/add_serial_no_composite_index_develop
perf: composite index on (serial_no, warehouse, posting_datetime) for Serial and Batch Entry
2026-06-19 17:00:50 +05:30
rohitwaghchaure
81dea34dd3 Merge pull request #56077 from aerele/fix/support-#69720
fix(stock): apply precision to the additional cost amount in stock entry
2026-06-19 16:52:04 +05:30
Nabin Hait
be05e01bd7 Merge pull request #56151 from nabinhait/refactor-si-mapper
refactor(sales_invoice): shrink make_inter_company_transaction mapper
2026-06-19 16:47:56 +05:30
Nabin Hait
61927b61fe Merge pull request #56139 from nabinhait/refactor-si-timesheet-billing
refactor(sales_invoice): simplify TimesheetBillingService link decision
2026-06-19 16:47:16 +05:30
ruthra kumar
f8550838a3 Merge pull request #55265 from aerele/bank-guarantee-type
fix: update reference doctype mapping and field visibility in bank guarantee
2026-06-19 16:38:53 +05:30
Rohit Waghchaure
9e15e52847 feat: allocate full actual charge to stock items only (e.g. Freight)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:35:45 +05:30
Mihir Kandoi
a954539b53 refactor(postgres): port sales_analytics tree/order-type queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:23 +05:30
Mihir Kandoi
f8120d1818 refactor(postgres): port point_of_sale get_items to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:21 +05:30
Nabin Hait
d5d2e3406b Merge pull request #56144 from nabinhait/refactor-si-status
refactor(sales_invoice): simplify StatusService.set_status, cover set_indicator
2026-06-19 16:17:11 +05:30
Mihir Kandoi
a80be19081 refactor(postgres): port sales_funnel funnel counts to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:34 +05:30
Mihir Kandoi
9ce1b02e6e refactor(postgres): rebuild available_stock_for_packing_items report without raw SQL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:32 +05:30
Mihir Kandoi
f4d9869d7b refactor(postgres): port customer_acquisition_and_loyalty report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:31 +05:30
Mihir Kandoi
6b1e339ed4 refactor(postgres): port customer_credit_balance report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:29 +05:30
Mihir Kandoi
fe13c0709b refactor(postgres): port pending_so_items_for_purchase_request report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:28 +05:30
Mihir Kandoi
c86aa3e3ad refactor(postgres): port sales_order_analysis report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:26 +05:30
Mihir Kandoi
60e05bdaa6 refactor(postgres): port quotation.set_expired_status off multisql to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:25 +05:30
Nabin Hait
4f42f52306 Merge pull request #56147 from nabinhait/refactor-si-gl-helper-names
refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
2026-06-19 16:12:30 +05:30
Nabin Hait
e85f2c4fbc refactor(sales_invoice): shrink make_inter_company_transaction mapper
The function was 199 lines, dominated by a 102-line update_details closure.
Extract the two party-mapping branches into module helpers
_apply_purchase_party_details / _apply_sales_party_details and the address
lookup into _get_linked_address; update_details is now a 6-line dispatcher.
make_inter_company_transaction drops to ~104 lines. No behaviour change
(inter-company SI->PI and PO->SO suites green).
2026-06-19 16:09:49 +05:30
Mihir Kandoi
bbc684aa80 Merge pull request #56142 from mihir-kandoi/pg-projects
refactor(postgres): port Projects module queries to the query builder
2026-06-19 15:54:52 +05:30
Nabin Hait
cb97c3a55a refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
Rename three private helpers to follow the verb-prefixed convention used
across the services:
- _amount_in_account_currency -> _get_amount_in_account_currency
- _return_aware_against_voucher -> _resolve_against_voucher
- _sdbnb_booking_for_item -> _get_sdbnb_booking_for_item

Pure rename of private methods, no behaviour change.
2026-06-19 15:49:17 +05:30
Nabin Hait
cb6fc640ce refactor(sales_invoice): simplify StatusService.set_status and cover set_indicator
set_status was a single status-resolution cascade (cyclomatic complexity
C/19). Extract the submitted-invoice resolution into _submitted_status (with
the invoice-discounting suffix) and _payment_status (guard clauses), leaving
the cancelled/draft quirks inline. set_status drops C/19 -> B/7; no C-rank
function remains in the module.

Add a characterization test for set_indicator, which was 93% untested -
the portal indicator colour/title for credit-note / unpaid / overdue /
return / paid states. Behaviour is unchanged (status and invoice-discounting
suites green).
2026-06-19 15:44:55 +05:30
Nabin Hait
3d44b4d98c refactor(sales_invoice): simplify TimesheetBillingService._update_time_sheet_detail
The link-decision was a single four-way boolean OR (cyclomatic complexity
C/16) where every branch repeated 'args.timesheet_detail == data.name'.
Factor that match out as a loop guard and extract the remaining decision into
_should_set_sales_invoice as ordered guard clauses. Behaviour is unchanged
(project, link-on-submit, unlink-on-cancel and return paths preserved);
characterization tests and the full timesheet suite are green.
2026-06-19 15:31:31 +05:30
Nabin Hait
dd7891e18f test(timesheet): characterize sales-invoice link/unlink and submit guard
Pin TimesheetBillingService behaviour before refactor: billing a timesheet
into a Sales Invoice links the timesheet detail and marks it Billed on submit,
and clears the link / reverts status on cancel; an unsubmitted timesheet
cannot be invoiced.
2026-06-19 15:31:31 +05:30
Mihir Kandoi
ea665d1a9b refactor(postgres): port Projects module queries to the query builder
Convert raw `frappe.db.sql` across the Projects module to `frappe.qb` / the ORM
so the same code runs on MariaDB and Postgres. Behaviour is preserved on
MariaDB; the conversions also make these paths valid under Postgres' stricter
SQL (GROUP BY, case-sensitivity, empty-string handling).

Conversions of note (behaviour kept identical to the MariaDB original):
- project.get_users_for_project: search selects the stored full_name instead of
  concat_ws(first, middle, last) (concat_ws diverges on Postgres, where empty
  Data fields are NULL) and wraps Locate in LOWER() to keep MariaDB's
  case-insensitive result ordering.
- project costing: percent-complete and sales/billed-amount aggregates rebuilt
  as Sum() query-builder selects.
- task.reschedule_dependent_tasks: the correlated subquery is split into a
  `Task Depends On` parent-pluck + a Task lookup (same rows, no nested SQL).
- timesheet.get_events: user-permission match conditions move to the query-builder
  form via get_event_conditions_qb; calendar columns rebuilt with Concat/Round.
- report/project_wise_stock_tracking & report/daily_timesheet_summary: GROUP BY
  cost aggregates and the timesheet date window (timestamp(to_date,'24:00:00') ->
  end-of-day via get_combine_datetime) rebuilt to satisfy Postgres.
- search helpers (query_task, get_project, get_timesheet) use frappe.qb.get_query
  with ignore_permissions=False in place of build_match_conditions/get_match_cond.

Tests (run on both MariaDB and Postgres, --lightmode):
- Existing project/task/timesheet/activity_cost suites kept green (27 tests).
- New project_wise_stock_tracking test drives all three cost aggregates with
  positive data (purchased / issued / delivered GROUP BY) plus get_project_details.
- New daily_timesheet_summary test covers the date-window join.

Not included: project_update.py is deferred. Its daily_reminder()/email_sending()
select `progress`/`progress_details`, columns that do not exist on the Project
Update doctype, so the function errors when invoked regardless of backend - a
pre-existing bug that needs an email-rework, not just a query port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:22:20 +05:30
Nabin Hait
6255495cc4 Merge pull request #55897 from nabinhait/fix/project-sales-order-link-overwrite
fix(projects): don't overwrite existing Sales Order project link
2026-06-19 15:15:35 +05:30
Diptanil Saha
8c1a1aafe6 fix(coa_importer): allow importing COA through import_coa only for Accounts Manager (#56132)
* fix(coa_importer): allow importing COA only for `Accounts Manager`

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>

* fix(coa_importer): check permissions in `unset_existing_data`

---------

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>
2026-06-19 15:08:20 +05:30
Mihir Kandoi
0a9aa448c1 Merge pull request #56134 from mihir-kandoi/pg-setup-utilities-templates-regional
refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder
2026-06-19 15:02:56 +05:30
Mihir Kandoi
02f7cba20a Merge pull request #55920 from ljain112/fix-scio-customer-material-avg-rate
fix: update weighted average rate calculation to consider returned and consumed quantities
2026-06-19 14:47:16 +05:30
Mihir Kandoi
96d4c48357 refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder
Convert raw `frappe.db.sql` in the Setup, Utilities, Templates and Regional
areas to `frappe.qb` / the ORM so the same code runs on MariaDB and Postgres.
Behaviour is preserved on MariaDB; the conversions also make these paths valid
under Postgres' stricter SQL (GROUP BY, case-sensitivity, reserved words).

Conversions of note (behaviour kept identical to the MariaDB original):
- email_digest: ToDo ordering replicated with a CASE that mirrors MySQL
  `field(priority,'High','Medium','Low')` (unknown/NULL -> 0, sorts first),
  NULL-date-first and a `name` tie-break for a deterministic LIMIT.
- company.get_all_transactions_annual_history: the cross-DocType UNION + GROUP BY
  is replaced by one grouped query per DocType merged with a Counter, so two
  different DocTypes sharing a transaction_date still collapse into one bucket.
- templates/utils.send_message: contact lookup wraps both sides in LOWER() to
  keep MariaDB's case-insensitive email match on case-sensitive Postgres.
- regional/irs_1099 & uae_vat_201: address ranking and emirate aggregation
  rebuilt with CASE/aggregate selects that satisfy Postgres GROUP BY, with a
  deterministic tie-break on the LIMIT-1 address lookups.
- utilities/product.get_item_codes_by_attributes: numeric attribute values are
  cast with cstr() so Postgres doesn't reject `varchar = numeric`.

Tests (run on both MariaDB and Postgres, --lightmode):
- New: company merge test, authorization_rule duplicate-check, youtube report,
  templates/utils, and utilities/templates page reports (partners, rfq,
  material_request_info, product, utilities __init__).
- Existing suites kept green: company, email_digest, transaction_deletion_record,
  irs_1099, uae_vat_201.

Deferred (tracked separately):
- setup/doctype/authorization_control.py still has raw `.format()` SELECTs;
  left for its own PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:37:50 +05:30
Nabin Hait
db2e2105ab fix: Use get_value instead of get_doc
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-19 14:37:32 +05:30
Nabin Hait
e2fbc48b9a Merge pull request #55898 from nabinhait/fix/stock-closing-entry-wrong-field
fix(stock): use correct field when reading previous stock closing balance
2026-06-19 14:36:21 +05:30
Mihir Kandoi
4180e29af4 Merge pull request #56130 from mihir-kandoi/pg-support
refactor(postgres): port Support module queries to the query builder
2026-06-19 13:52:10 +05:30
Mihir Kandoi
39eb34f333 refactor(postgres): address Greptile review on warranty_claim on_cancel
- Filter the parent Maintenance Visit's docstatus (mv.docstatus != 2) via a qb join, as
  the original SQL did, instead of the child Maintenance Visit Purpose row's docstatus.
  Synced in normal flows, but exactly faithful to the original intent.
- Add a limit(500) to bound the read on a cancellation path.

Adds two both-engine tests calling on_cancel directly: an active (non-cancelled) visit
blocks the claim cancel; with no referencing visit the claim is marked Cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:13:14 +05:30
Mihir Kandoi
c6f9415e9d Merge pull request #56129 from mihir-kandoi/fix-test-workstation-warehouse
fix(tests): point _Test Workstation 1 fixture at an existing warehouse
2026-06-19 13:06:48 +05:30
Mihir Kandoi
f768778d81 refactor(postgres): port Support module queries to the query builder
Convert the remaining raw frappe.db.sql in the Support module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- issue.py, warranty_claim.py (Maintenance Visit lookup / make_maintenance_visit)
- reports: first_response_time_for_issues and issue_summary (GROUP BY on the grouped
  Date(creation)/based-on field + Avg/Count -- Postgres-valid), support_hour_distribution

Tests: existing issue suite (35) passes on both engines; adds both-engine tests for the
previously-untested warranty_claim mapper (3) and the three reports. All green on
MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:58:52 +05:30
Mihir Kandoi
c541bc9239 fix(tests): point _Test Workstation 1 fixture at an existing warehouse
BootStrapTestData.make_workstation() created _Test Workstation 1 referencing the
warehouse "_Test warehouse - _TC" (lowercase 'w'), but make_warehouse() never
creates that name -- it makes "_Test Warehouse - _TC" (capital W) and others.

On MariaDB the lowercase reference resolves to the capital warehouse via the default
case-insensitive collation, so it silently works. On PostgreSQL (case-sensitive) the
link cannot be found, so on a freshly-bootstrapped site make_workstation() raises
LinkValidationError: Could not find Warehouse: _Test warehouse - _TC, which aborts the
module-level BootStrapTestData() import and blocks every test extending ERPNextTestSuite.
It was masked only on sites where the workstation was already bootstrapped (make_records
skips existing) or where the lowercase name happened to exist from legacy data.

Point the fixture at the existing "_Test Warehouse - _TC". Verified on a freshly-reset
site: the buggy fixture raises the LinkValidationError on Postgres and passes after the
fix; MariaDB passes either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:47:19 +05:30
Nabin Hait
817c5007d9 Merge pull request #55899 from nabinhait/fix/margin-currency-rate-refresh
fix: don't convert item margin on exchange rate refresh
2026-06-19 12:42:21 +05:30
Nabin Hait
900c71840c test(projects): set company on second project to fix CI mandatory error
The second Project in test_sales_order_link_is_not_overwritten_by_second_project
was inserted without a company, which only succeeds when a default company is
configured. On a fresh CI site this raised MandatoryError. Set company from the
sales order explicitly.
2026-06-19 12:37:43 +05:30
Nabin Hait
dfd0c85ba4 Merge pull request #56083 from nabinhait/refactor-si-pos-service
refactor(sales_invoice): tidy POSService (set_pos_fields + mode-of-payment queries)
2026-06-19 12:26:19 +05:30
Nabin Hait
8caaac96b6 Merge pull request #56086 from nabinhait/refactor-si-gl-composer
refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
2026-06-19 12:03:21 +05:30
Nabin Hait
9f02c47592 refactor(sales_invoice): decompose POSService.set_pos_fields and dedupe MOP queries
set_pos_fields was a 97-line method (cyclomatic complexity E/36). Split
it into a small orchestrator plus focused helpers, each preserving the
exact for_validate semantics (A/5 after).

Collapse the three near-identical mode-of-payment query builders onto a
shared _enabled_mode_of_payment_query, and add type hints and docstrings.
Public signatures and return shapes are unchanged.
2026-06-19 11:58:43 +05:30
Nabin Hait
7f47c218ce test(sales_invoice): characterize POSService default and mode-of-payment logic
Pin the behaviour of POSService.set_pos_fields (POS-profile default
resolution and the for_validate guard) and the mode-of-payment query
helpers before refactoring them.
2026-06-19 11:57:40 +05:30
Nabin Hait
ae11b3b848 Merge pull request #56085 from nabinhait/fix-pos-get-warehouse
fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
2026-06-19 11:52:41 +05:30
Mihir Kandoi
64e177df8b Merge pull request #56125 from mihir-kandoi/pg-crm 2026-06-19 10:44:48 +05:30
Mihir Kandoi
413ec60a3e refactor(postgres): address Greptile review on prospects report
- Fix N+1: the per-lead conversion issued 4 queries per lead (Opportunity, Quotation,
  Issue, Communication). Collect the reference documents for all leads in 3 bulk
  queries, then one Communication query per lead -> ~N+3 round-trips instead of 4N,
  matching the original single-query-per-lead cost.
- Constrain reference_doctype in the Communication lookup (names are unique only within
  a doctype), closing a latent cross-doctype name-collision gap the original also had.

Both-engine test still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:26:45 +05:30
Mihir Kandoi
6733681e93 Merge pull request #56124 from mihir-kandoi/pg-assets 2026-06-19 10:22:21 +05:30
Mihir Kandoi
5104007d12 refactor(postgres): port CRM module queries to the query builder
Convert the remaining raw frappe.db.sql in the CRM module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- opportunity.py, doctype/utils.py (get_last_interaction)
- reports: campaign_efficiency, first_response_time_for_opportunity (GROUP BY on the
  grouped Date(creation) + Avg -- Postgres-valid), lead_conversion_time,
  prospects_engaged_but_not_converted

lead_conversion_time also keeps the IS NOT NULL communication-date guard (forward-port
of the fix already on develop). Also drops invalid backtick notation from two get_all
order_by clauses in doctype/utils.py (order_by="`creation` DESC"), which frappe's
query engine rejects -- a latent failure on both engines, surfaced by the new test.

Tests: existing opportunity suite plus new both-engine tests for the four previously
untested reports/utils. All green on MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:15:20 +05:30
Mihir Kandoi
4bc3420b21 refactor(postgres): address Greptile review on Assets conversions
- cancel_movement_entries: filter the parent Asset Movement's docstatus via a qb join
  (as the original SQL did), instead of the child Asset Movement Item.docstatus. Behaviour
  is identical in normal flows (child docstatus is synced) but this is exactly faithful.
- get_maintenance_log: add a both-engine test for this previously-untested whitelisted
  endpoint. Confirms the frappe v16 dict aggregate field spec ({"COUNT": ...}) runs and
  returns correct per-status counts (no runtime crash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:02:59 +05:30
Mihir Kandoi
fc9608d14d refactor(postgres): port Assets module queries to the query builder
Convert the remaining raw frappe.db.sql in the Assets module to frappe.qb / ORM so
the queries run on PostgreSQL as well as MariaDB. Faithful 1:1 conversions -- no
MariaDB behaviour change:

- asset.py (gl-entry / bom-cost fetches), asset_maintenance.py (team members),
  asset_movement.py (latest location/custodian), location.py (get_children)
- fixed_asset_register.py: the depreciation-amount aggregate groups by asset.name
  (the primary key) selecting only asset.name + Sum(gle.debit), which is valid under
  Postgres strict GROUP BY (PK functional dependency)

Tests: existing asset (61), asset_maintenance, asset_movement and location suites
pass on both engines; adds a test for the previously-untested Fixed Asset Register
report (covers the GROUP BY aggregate on both engines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:48:35 +05:30
Mihir Kandoi
facb27c3f4 Merge pull request #56089 from mihir-kandoi/pg-ar-future-payments
fix(accounts): Journal Entry future payments mis-allocated to one invoice in Accounts Receivable
2026-06-19 08:50:35 +05:30
Smit Vora
68a1fe1480 Merge pull request #56104 from frappe/fix-inclusive-payment-entry-none-base-tax-amount
fix: base_tax_amount as none when payment entry created using API
2026-06-19 08:47:18 +05:30
Mihir Kandoi
e34a64ecee Merge pull request #56118 from frappe/mergify/bp/develop/pr-56065
fix(stock): propagate renamed attribute values to variant items (backport #56065)
2026-06-19 07:42:50 +05:30
MochaMind
060cd9f320 fix: Bosnian translations 2026-06-18 23:55:57 +05:30
MochaMind
eb6530208b fix: Croatian translations 2026-06-18 23:55:52 +05:30
Mihir Kandoi
98e012095a Merge pull request #56113 from mihir-kandoi/pg-accounts-sales-payment-summary
fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
2026-06-18 23:03:47 +05:30
Mihir Kandoi
e8bebba915 Merge pull request #56112 from mihir-kandoi/pg-accounts-budget
fix(postgres): port budget queries to qb and fix requested-amount aggregation
2026-06-18 23:01:01 +05:30
Mihir Kandoi
d3c0d9b283 fix: resolve item attribute backport conflict 2026-06-18 22:38:23 +05:30
Mihir Kandoi
1cfb41e1c4 Merge pull request #56111 from mihir-kandoi/pg-accounts-doctypes
refactor(postgres): port accounts doctypes & match-condition helper to qb
2026-06-18 22:35:44 +05:30
barredterra
0e244dd83a fix(stock): update variant attributes on value rename
(cherry picked from commit c7acd88742)
2026-06-18 17:05:40 +00:00
barredterra
4c29d5630d test(stock): add cleanup for item attribute value changes in tests
(cherry picked from commit 60f5de7ab8)
2026-06-18 17:05:40 +00:00
barredterra
4806b82add fix(stock): propagate renamed attribute values to variant items
(cherry picked from commit 27d574dad5)

# Conflicts:
#	erpnext/stock/doctype/item_attribute/item_attribute.py
2026-06-18 17:05:40 +00:00
Mihir Kandoi
5a80278d1e Merge pull request #56098 from aerele/fix/serial-no-work-order-docstatus-filter
fix: apply docstatus filter to exclude cancelled Work Orders in Seria…
2026-06-18 22:25:56 +05:30
Mihir Kandoi
c4e1fe274b Merge pull request #56055 from aerele/fix/support-71415
fix: disable is_debit_note while creating credit note
2026-06-18 22:23:39 +05:30
Mihir Kandoi
1a56f3b032 Merge pull request #56105 from mihir-kandoi/pg-parity-other-class
fix(postgres): MariaDB/Postgres parity in pick-list, serial match, null ordering & link-query ranking
2026-06-18 22:22:16 +05:30
Mihir Kandoi
08375a9e2f Merge pull request #56110 from mihir-kandoi/pg-accounts-gl-core
refactor(postgres): port GL & ledger-core queries to the query builder
2026-06-18 22:02:05 +05:30
Mihir Kandoi
fa378e2d7a test(sales_payment_summary): exercise the POS customer filter
Address review (#56113): add a customer filter to the is_pos test so the
customer-subquery fix is covered (a.customer was unselected before and errored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:34:37 +05:30
Mihir Kandoi
006a65e873 refactor(postgres): finish budget qb conversion (validate_expense_against_budget)
Address review / semgrep (#56112): convert the last raw f-string query
(budget_records, with a dynamic dimension column + tree EXISTS condition) to
frappe.qb, clearing the sql-format-injection finding. Also switch the two
remaining implicit comma-joins (get_requested_amount / get_ordered_amount) to
explicit .join().on() for consistency. test_budget 23/23 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:32:46 +05:30
Mihir Kandoi
e7b135b51e fix(postgres): aggregate bare account in pos_closing payments; explicit limit on gl-entry fetch
Address review (#56111):
- pos_closing get_payments grouped by mode_of_payment but selected a bare account ->
  Postgres GroupingError. Wrap in Max() (deterministic, both engines agree; account is
  consumed downstream for the change-amount adjustment). test_pos_closing_entry 9/9 both engines.
- get_voucherwise_gl_entries: add limit=0 to make the unbounded fetch explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:29:00 +05:30
Mihir Kandoi
dabc94ed06 Merge pull request #56101 from mihir-kandoi/pg-arbitrary-representative
fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
2026-06-18 21:06:33 +05:30
Mihir Kandoi
1f4702bde7 fix(crm): skip rows with no first-contact date in lead conversion time
Address review (#56105): when there's no matching communication, first_contact is
None and date_diff(invoice_date, None) treats None as today, giving a wrong
(negative) duration. Skip the entry instead, mirroring the communication_count guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:04:33 +05:30
Mihir Kandoi
4708ac4e3d fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
Converts the report's raw f-string SQL (incl. 3-way UNIONs) to frappe.qb. Split out of #56082.

The non-POS path is MariaDB-identical. get_pos_invoice_data had a loose GROUP BY that
errors on Postgres; line-level warehouse/cost_center/mode_of_payment are now Max()
(deterministic, both engines agree), the unused item_code dropped, and customer added to
the invoice subquery so the customer filter works (it referenced a column the subquery
never selected). + a test covering the previously-untested is_pos path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:35 +05:30
Mihir Kandoi
996a02180b fix(postgres): port budget queries to qb and fix requested-amount aggregation
Converts budget.py raw SQL to frappe.qb for Postgres compatibility. Split out of #56082.

Mostly MariaDB-identical, with one behaviour change: get_requested_amount summed
Sum(qty) * <arbitrary rate> (a bare rate column, invalid on Postgres and wrong when an
item was requested at different rates). Now Sum(qty * rate) per line -- correct, and
identical on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:33 +05:30
Mihir Kandoi
d8a2f53a29 refactor(postgres): port accounts doctypes & match-condition helper to the query builder
Pure MariaDB-identical conversion for Postgres compatibility. Split out of #56082.

bank_clearance, pos_closing_entry, process_statement_of_accounts, party, utils, and
sales_invoice/services/pos converted to frappe.qb; bundles erpnext/utilities/query.py
(the get_match_conditions_qb helper, frappe#40075 follow-up) which
process_statement_of_accounts depends on. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:11 +05:30
Mihir Kandoi
13d06e77b4 refactor(postgres): port GL & ledger-core queries to the query builder
Pure MariaDB-identical conversion (raw frappe.db.sql -> frappe.qb / portable
functions) for Postgres compatibility. Split out of #56082.

general_ledger, gl_entry, gl_validator, period_closing_voucher, deferred_revenue,
process_payment_reconciliation + their tests. No behaviour change on MariaDB;
verified equivalent and the suites pass on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:00:55 +05:30
Mihir Kandoi
5787951ed1 fix(crm): guard first_contact result before indexing (lead conversion time)
Address review (#56105): the IS NOT NULL guard can return no rows (the count above
filters on sender, this query on recipients), so [0][0] would raise IndexError.
Fall back to None when empty, matching the prior behaviour for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:50:31 +05:30
Mihir Kandoi
3f6f3abf69 fix(selling): make multi-order invoice split sum exactly to the grand total
Address review (#56101): with 3+ orders the proportional shares could drift by a
sub-cent and not sum back to the grand total, leaving the last payment term
"Partly Paid". The last order (sorted, so MariaDB and Postgres agree) now absorbs
the residual: grand_total - sum(prior shares). Extended the test to three orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:48:36 +05:30
Mihir Kandoi
055c58364a Merge pull request #56090 from mihir-kandoi/pg-bom-rowshape
fix(manufacturing): make arbitrary GROUP-BY picks deterministic in BOM & transfer queries
2026-06-18 20:46:30 +05:30
Mihir Kandoi
cfa6d286ad fix(postgres): other-class parity fixes (case-folding & null-ordering) for merged queries
Parity sweep findings in queries already merged in develop:

- pos_invoice.py: serial_no return-match matched case-sensitively on Postgres (case-insensitive
  on MariaDB). Replaced the get_all or_filters lookup with a qb query that case-folds both sides
  via Lower() (no-op on MariaDB).
- controllers/queries.py + pick_list.py: the Locate()-based relevance ranking in link-query
  ORDER BY is case-sensitive on Postgres (Strpos) vs case-insensitive on MariaDB (Locate), so
  autocomplete order differed. Lower() both arguments so the ranking matches on both engines.
- crm/lead_conversion_time.py: "first contact" used ORDER BY communication_date LIMIT 1 read by
  index; a NULL date sorts first on MariaDB but last on Postgres, changing the result. Added
  `communication_date IS NOT NULL` so both engines return the earliest real contact date.

Verified on MariaDB and Postgres: test_pick_list 40/40, test_pos_invoice 26/26 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:36:30 +05:30
vorasmit
b9b402f2ec fix: tax.base_tax_amount as none when payment entry created using API 2026-06-18 20:15:03 +05:30
Mihir Kandoi
b04a9e25ff fix(stock): make pick_list link query valid on Postgres (GROUP BY joined column)
get_pick_list_query selects Sales Order.customer (a joined table's column) while
grouping only by Pick List.name. Postgres' functional-dependency relaxation applies
to a table's own primary key, not to a joined table's columns, so the query raises
GroupingError on Postgres. MariaDB arbitrary-picks and runs.

customer is already pinned to a single value by `WHERE Sales Order.customer = filter`,
so adding it to the GROUP BY is identical on MariaDB and valid on Postgres.

Test (errors with GroupingError on the old code on Postgres, passes on both engines):
- test_get_pick_list_query_postgres_valid: a submitted pick list for a customer is
  returned by the link query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:14:31 +05:30
Mihir Kandoi
b1c6666d02 fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
payment_terms_status_for_sales_order grouped invoice rows by `sii.parent` and took
`Max(sii.sales_order)`, so an invoice that bills several Sales Orders was credited
in full to one arbitrary order and the rest were starved of that payment.

get_so_with_invoices now returns one row per (invoice, sales_order) and splits the
invoice's grand total across the orders in proportion to each order's net line
amount on that invoice. A single-order invoice keeps the full grand total (ratio 1),
so the common case is unchanged; the split is pure Python over deterministic SQL,
so MariaDB and Postgres produce identical results (100% parity).

Test (fails on the old code, passes on both engines):
- test_invoice_billing_multiple_orders_splits_proportionally: one invoice billing two
  SOs 600/400 -> each order credited its share, summing to the grand total. Old
  Max(sales_order) collapsed the invoice onto one order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:44:31 +05:30
Mihir Kandoi
fe0465f16e fix(manufacturing): deterministic arbitrary-pick in BOM explosion & WO transfer tracking
Same class as the sub_assembly_queries / bom_stock_analysis fixes in this PR: two
more merged queries wrapped a non-functionally-dependent column in Max(), so the
engines can disagree and the value is wrong for the case the column drives.

bom_explosion._subitems_query — Max(is_phantom_item):
  Rows are grouped by item_code and get_subitems() drops any grouped row whose
  is_phantom_item is truthy. When one item_code is listed in a BOM both as a
  phantom sub-assembly and as a plain raw material, Max() returns 1 and the real
  raw material is silently dropped from the plan. Use Min(): an item is phantom
  only when EVERY line for it is phantom, so a real material is never lost.

required_items._material_transfer_qty_by_item — Max(original_item):
  original_item is the output dict key. The same item B can be transferred both
  for itself (original_item NULL) and as a substitute for required item A
  (original_item=A). Grouping by item_code alone with Max() merged the two and
  credited B's whole transfer to A, leaving B at 0. Group by
  (item_code, original_item) and accumulate into the keyed dict so each transfer
  is credited to the right required item (two rows can resolve to one key, e.g.
  A's own transfer and B-for-A, hence += not plain assignment).

Both were previously undefined SQL (loose GROUP BY); the fix makes MariaDB and
Postgres agree on the correct, deterministic value. Other Max()-wrapped columns
in these queries are functionally dependent on the grouped item and unchanged.

Tests (fail on the old code, pass on both engines):
- test_subitems_query_keeps_real_rm_listed_alongside_phantom
- test_transferred_qty_not_misattributed_between_item_and_its_substitute

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:03:26 +05:30
pandiyan
3ba8f690a4 fix: apply docstatus filter to exclude cancelled Work Orders in Serial No 2026-06-18 18:29:08 +05:30
rohitwaghchaure
47a9c54b70 Merge pull request #55931 from DipenFrappe/fix-rfq-accounting-dimensions
feat(rfq): add accounting dimensions support to Request for Quotation
2026-06-18 18:27:49 +05:30
Shllokkk
336307f287 Merge pull request #56087 from Shllokkk/pcv-je-validation
fix(journal entry): validate opening entry against pcv on save
2026-06-18 18:04:33 +05:30
Dipen Gala
79421bcfcc fix: set cost_center on RFQ item before submitting in test
The test was mutating an already-submitted RFQ, which raised
UpdateAfterSubmitError because cost_center lacks allow_on_submit.
Use do_not_submit=True, set cost_center on the draft, save, then submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:21:26 +05:30
Shllokkk
e23a7883f3 fix(journal entry): validate opening entry against pcv on save 2026-06-18 16:57:00 +05:30
Shllokkk
deff5848ed Merge pull request #55504 from aerele/fix/soa-show-opening-entries
fix(accounts): allow process statement of account generation with opening entries
2026-06-18 16:44:25 +05:30
Mihir Kandoi
b579dbc1e6 fix(manufacturing): prefer the phantom line as bom_stock_analysis representative
Address review on #56090: get_bom_data groups components by item_code, so it
picks one representative BOM Item line for the (bom_no, is_phantom_item) pair.
Taking the first line by idx dropped the phantom flag when a non-phantom line
was listed before the phantom one, so explode_phantom_boms skipped the sub-BOM.

Keep one row per item_code (preserving the qty_per_unit total per component
rather than widening the GROUP BY), but make the representative phantom-
preferring: the first line, upgraded to the first phantom line if any exists.
A phantom sub-BOM is therefore never dropped due to line order, on either engine.

Adds test_phantom_explosion_when_phantom_line_is_not_first (phantom line at
idx 2) alongside the existing idx-1 case; both pass on MariaDB and Postgres and
the new one fails on the naive first-line representative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:27:23 +05:30
Mihir Kandoi
41da9eb7fc fix(manufacturing): keep bom_no/is_phantom_item pair coherent in sub-BOM resolution
When a component is listed more than once in a BOM pointing at different
sub-BOMs (e.g. one phantom, one not), two queries grouped the duplicate
lines into a single row and aggregated bom_no and is_phantom_item with
*independent* Max(). That could pair the phantom flag of one line with the
bom_no of another, so the consumer recursed into the wrong sub-BOM:

- sub_assembly_queries._sub_assembly_rm_query keys on (item_code, bom_no)
  and recurses on is_phantom_item. An incoherent pair sent raw-material
  resolution down the wrong sub-assembly BOM.
- bom_stock_analysis.get_bom_data: explode_phantom_boms recurses into
  bom_no only when is_phantom_item is set; an incoherent pair exploded a
  non-phantom sub-BOM as if it were phantom (or vice-versa).

Fix:
- sub_assembly_queries: group also by (bom_no, is_phantom_item) so each
  distinct sub-BOM is its own coherent row.
- bom_stock_analysis: drop the two independent Max()es and attach a single
  representative line (lowest idx) per item_code before exploding.

This was previously undefined SQL (loose GROUP BY); the fix makes MariaDB
and Postgres agree on a deterministic, coherent pairing. Other Max()-wrapped
columns are functionally dependent on the grouped item and keep their value
on both engines.

Tests (fail on the old code, pass on both engines):
- test_phantom_explosion_picks_coherent_sub_bom: duplicate-component BOM
  explodes the phantom sub-BOM, not the lexically-larger non-phantom one.
- test_sub_assembly_rm_query_keeps_bom_no_phantom_pair_coherent: the query
  returns one coherent row per distinct sub-BOM with the right phantom flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:14:30 +05:30
Mihir Kandoi
1cc98a82ba fix(accounts): Journal Entry future payments mis-allocated to one invoice in AR
get_future_payments_from_journal_entry has no GROUP BY: the Sum() makes it an
implicit single-group aggregate, so every future-dated JE payment company-wide
collapses into ONE row keyed by an arbitrary (invoice, party). The Accounts
Receivable report then allocates the entire future sum against that one invoice and
shows zero future payment for every other invoice. This predates the postgres work
(MariaDB returned an arbitrary single row); the qb conversion only made the arbitrary
pick deterministic via Max().

Add an explicit GROUP BY (je.name, jea.reference_name, jea.party, jea.party_type,
je.posting_date, je.cheque_no) and drop the Max() wrappers, so each (JE, invoice,
party) is its own future-payment row -- matching the Payment Entry path and the
(invoice_no, party) keying the report's allocator already expects. Identical on
MariaDB and PostgreSQL.

Ships a JE-path future-payment test (one future JE paying two invoices -> each
invoice keeps its own future amount; fails on the old code with 0 != 50).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:53:57 +05:30
Mihir Kandoi
eb7f7f2124 Merge pull request #56081 from mihir-kandoi/pg-query-report-json
fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
2026-06-18 15:18:49 +05:30
Nabin Hait
fcd312f205 refactor(sales_invoice): decompose SDBNB and change-amount GL methods
stock_delivered_but_not_billed_gl_entries (C/15) is split into a thin loop
plus _sdbnb_booking_for_item (eligibility + valuation), _is_sdbnb_reversal
(the account predicate) and _append_sdbnb_gl_entries (the two GL rows) —
guard clauses replace the nested continues. get_gle_for_change_amount now
uses the shared _amount_in_account_currency helper. No C-rank functions
remain in the file. Behaviour unchanged; characterization tests and the
full Sales Invoice suite (133) green.
2026-06-18 15:03:32 +05:30
Mihir Kandoi
3d4b50d37d fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
Three Query Reports embedded double-quoted string literals and an unquoted table
identifier that error on PostgreSQL. Portability-only, no behaviour change on either engine:
- material_requests_for_which_supplier_quotations_are_not_created,
  requested_items_to_be_transferred: double-quoted string literals ("Stopped",
  "Material Transfer") -> single quotes (double quotes are identifiers on postgres, not strings).
- items_to_be_requested: quote the `tabBin` table identifier so postgres doesn't lower-case it.

(received_items_to_be_billed was dropped: it is a Script Report, so its `query` field is dead
code and the fix never reaches the DB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:01 +05:30
Nabin Hait
5de87f473e test(sales_invoice): characterize SDBNB and change-amount GL entries
Pin the two least-covered SalesInvoiceGLComposer methods before refactor:
- stock_delivered_but_not_billed_gl_entries: billing a perpetual Delivery
  Note via Sales Invoice reverses the SDBNB account into COGS for an equal
  amount.
- get_gle_for_change_amount: empty without change, mandatory-account error,
  and the debit-to / change-account entry pair.
2026-06-18 14:55:37 +05:30
rohitwaghchaure
31849f6029 Merge pull request #56079 from rohitwaghchaure/allow-negative-stock-for-batch-level
feat: allow negative stock at batch level
2026-06-18 14:31:09 +05:30
Nabin Hait
1f06f2e3a0 refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
Extract two cross-cutting helpers used across the GL methods:
- _amount_in_account_currency: the repeated 'base if account is in company
  currency else transaction amount' ternary (customer, tax, item, POS and
  write-off entries).
- _return_aware_against_voucher: the return/self-outstanding against_voucher
  rule duplicated in the customer and POS entries.

Pull the per-item income and discount rows into their own builders and
flatten make_item_gl_entries with a guard clause. Complexity drops:
make_customer C11->B7, make_pos C12->B7, make_item C14->B10,
make_discount C13->B10, make_tax->A3. No behaviour change; entry dicts and
amounts are identical (full Sales Invoice suite green).

stock_delivered_but_not_billed_gl_entries is left for a coverage-first pass
(it is the least-tested GL branch).
2026-06-18 14:16:41 +05:30
Nabin Hait
3038ad8abe fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
get_warehouse could never run: it filtered POS Profile on a non-existent
'user' column (users live in the applicable_for_users child table), and
embedded a Python bool (frappe.session["user"] == "") inside a query
builder predicate, which raises before reaching the database. It also
has no callers. Remove it and the now-unused msgprint import.
2026-06-18 14:00:07 +05:30
Khushi Rawat
dc202ac4a2 Merge pull request #56030 from Shllokkk/budget-distribution-grid-lock
fix: lock budget distribution table and guard against null distributi…
2026-06-18 13:58:07 +05:30
Rohit Waghchaure
ca07982ee0 feat: add batch-level option to allow negative stock for batch 2026-06-18 13:41:09 +05:30
Mihir Kandoi
f269f6a8d8 Merge pull request #56062 from mihir-kandoi/pg-accounts-pos
refactor(postgres): port Accounts POS, pricing & invoicing doctype queries to the query builder
2026-06-18 13:22:49 +05:30
Sudharsanan11
2ca1bdd8a7 test(stock): add test to validate the precision for additional cost amount 2026-06-18 12:12:05 +05:30
Sudharsanan11
cf338bb757 fix(stock): apply precision to the additional cost amount in stock entry 2026-06-18 12:12:05 +05:30
Shllokkk
d37e5cd97d fix: lock budget distribution table and guard against null distribution rows 2026-06-18 11:23:39 +05:30
MochaMind
526f91f6b5 fix: Bosnian translations 2026-06-17 23:22:48 +05:30
MochaMind
4465ebaeb5 fix: Persian translations 2026-06-17 23:22:44 +05:30
Mihir Kandoi
88cb132fd1 refactor(postgres): port Purchase Invoice expense-account queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:39 +05:30
Mihir Kandoi
d23677636d refactor(postgres): port Purchase Invoice GL composer queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:38 +05:30
Mihir Kandoi
8e0ba50c4d refactor(postgres): port Purchase Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
08abf96047 refactor(postgres): port Fiscal Year doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
813b42d706 refactor(postgres): port Cost Center doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:36 +05:30
Mihir Kandoi
8ce63dac65 refactor(postgres): port Sales Taxes and Charges Template queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
8e9680afce refactor(postgres): port Pricing Rule utils queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
a09e875109 refactor(postgres): port Loyalty Point Entry doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:34 +05:30
Mihir Kandoi
42c61915c4 refactor(postgres): port POS Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
Mihir Kandoi
37a6ebd431 refactor(postgres): port POS Profile doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
pandiyan
279c8dea06 fix: disable is_debit_note while creating credit note 2026-06-17 18:35:10 +05:30
Rohit Waghchaure
b1b6ae98ed perf: composite index on (serial_no, warehouse, posting_datetime) 2026-06-17 12:17:11 +05:30
Dipen Gala
e91bcd6dd6 feat(rfq): add accounting dimensions support to Request for Quotation
- Add `cost_center` field and `accounting_dimensions_section` / `dimension_col_break`
  to Request for Quotation Item DocType so custom accounting dimensions propagate automatically
- Register `Request for Quotation Item` in `accounting_dimension_doctypes` hook
- Map `cost_center` from Material Request → RFQ in `make_request_for_quotation`
- Map `cost_center` from RFQ → Supplier Quotation in `make_supplier_quotation_from_rfq`
  and `create_rfq_items` (portal flow)
- Map `cost_center` in `get_item_from_material_requests_based_on_supplier` (MR-based RFQ flow)
- Add test cases to verify cost_center propagation through the MR → RFQ → SQ chain

Fixes #55855

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:58:57 +05:30
ljain112
35e55d3e13 fix: update weighted average rate calculation to consider returned and consumed quantities 2026-06-15 14:16:07 +05:30
Nabin Hait
6f9a8ff101 fix: don't convert item margin on exchange rate refresh
Changing the transaction/posting date re-triggers the `currency` handler,
which fetches a fresh exchange rate and, whenever it differs from the
current one, divided every Amount-type item margin and Actual tax charge
by the new rate. `margin_rate_or_amount` is an amount in the transaction
currency, so dividing it is only meaningful when the document actually
switches currency; on a mere rate refresh it silently shrinks the margin
every time.

Track the currency the rendered document is denominated in and convert
margins/actual charges only on a real currency change, while still
updating `conversion_rate` so base amounts recalculate correctly.

Also remove the duplicated `currency()` override in quotation.js: it
re-ran the same fetch-and-convert block after `super.currency()` (double
converting margins) and lacked the `load_after_mapping` guard. The base
handler already covers Quotation via `transaction_date`.

Fixes #45210
2026-06-14 20:32:56 +05:30
Nabin Hait
8e627db785 fix(stock): use correct field when reading previous stock closing balance
`StockClosing.get_sle_entries` filtered `Stock Closing Balance` by a
`closing_stock_balance` column that does not exist; the link field back to
the closing entry is `stock_closing_entry`. As a result every Stock
Closing Entry created after the first one failed with
`OperationalError (1054, "Unknown column 'closing_stock_balance'")`, since
the previous-balance branch only runs once an earlier closing exists.

Fixes #54819
2026-06-14 20:25:57 +05:30
Nabin Hait
b2eb6a69c1 fix(projects): don't overwrite existing Sales Order project link
Creating a Project with a `sales_order` set used `frappe.db.set_value`
to unconditionally write the Sales Order's `project` field. When several
projects were created for the same Sales Order, each new project silently
overwrote the previous link, leaving earlier projects orphaned.

Only back-link the Sales Order when it is not already tied to another
project, and write the value through the document's `db_set` so the
modified timestamp and realtime update are handled. A warning is shown
when an existing link is left untouched.

Fixes #52179
2026-06-14 20:17:08 +05:30
S Sakthivel Murugan
d0f1239d2b fix(accounts): allow process statement of account generation with opening entries 2026-06-11 15:51:20 +05:30
nareshkannasln
b1de654dfd fix: update reference doctype mapping and field visibility in bank guarantee 2026-05-26 12:39:51 +05:30
177 changed files with 7096 additions and 2943 deletions

View File

@@ -1,6 +1,7 @@
import frappe
from frappe import _
from frappe.email import sendmail_to_system_managers
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
def build_conditions(process_type, account, company):
conditions = ""
deferred_account = (
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
)
if process_type == "Income":
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
deferred_account = item.deferred_revenue_account
else:
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
deferred_account = item.deferred_expense_account
if account:
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
return deferred_account == account
elif company:
conditions += f"AND p.company = {frappe.db.escape(company)}"
return parent.company == company
return conditions
return None
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
end_date = add_days(today(), -1)
# check for the purchase invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_expense = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_expense == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
# For each invoice, book deferred expense
for invoice in invoices:
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
send_mail(deferred_process)
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
end_date = add_days(today(), -1)
# check for the sales invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabSales Invoice Item` item, `tabSales Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_revenue = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_revenue == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
for invoice in invoices:
doc = frappe.get_doc("Sales Invoice", invoice)
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
)
if not prev_posting_date:
prev_gl_entry = frappe.db.sql(
"""
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
prev_gl_entry = frappe.get_all(
"GL Entry",
filters={
"company": doc.company,
"account": item.get(deferred_account),
"voucher_type": doc.doctype,
"voucher_no": doc.name,
"voucher_detail_no": item.name,
"is_cancelled": 0,
},
fields=["name", "posting_date"],
order_by="posting_date desc",
limit=1,
)
prev_gl_via_je = frappe.db.sql(
"""
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
WHERE p.name = c.parent and p.company=%s and c.account=%s
and c.reference_type=%s and c.reference_name=%s
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
prev_gl_via_je = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(je.name, je.posting_date)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (jea.docstatus < 2)
)
.orderby(je.posting_date, order=frappe.qb.desc)
.limit(1)
.run(as_dict=True)
)
if prev_gl_via_je:
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
deferred_account = "deferred_expense_account"
gl_entries_details = frappe.db.sql(
"""
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
group by voucher_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
gle = frappe.qb.DocType("GL Entry")
gl_entries_details = (
frappe.qb.from_(gle)
.select(
Sum(gle[total_credit_debit]).as_("total_credit"),
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
gle.voucher_detail_no,
)
.where(
(gle.company == doc.company)
& (gle.account == item.get(deferred_account))
& (gle.voucher_type == doc.doctype)
& (gle.voucher_no == doc.name)
& (gle.voucher_detail_no == item.name)
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_detail_no)
.run(as_dict=True)
)
journal_entry_details = frappe.db.sql(
"""
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
and p.docstatus < 2 group by reference_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
journal_entry_details = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(
Sum(jea[total_credit_debit]).as_("total_credit"),
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
jea.reference_detail_no,
)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (je.docstatus < 2)
)
.groupby(jea.reference_detail_no)
.run(as_dict=True)
)
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0

View File

@@ -7,7 +7,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cint, flt, fmt_money, getdate
from pypika import Order
@@ -195,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
.select(
ConstantColumn("Journal Entry").as_("payment_document"),
journal_entry.name.as_("payment_entry"),
journal_entry.cheque_no.as_("cheque_number"),
journal_entry.cheque_date,
# non-grouped columns are constant per grouped JE name / account (against_account is
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
# same value MySQL picked.
Max(journal_entry.cheque_no).as_("cheque_number"),
Max(journal_entry.cheque_date).as_("cheque_date"),
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
journal_entry.posting_date,
journal_entry_account.against_account,
journal_entry.clearance_date,
journal_entry_account.account_currency,
Max(journal_entry.posting_date).as_("posting_date"),
Max(journal_entry_account.against_account).as_("against_account"),
Max(journal_entry.clearance_date).as_("clearance_date"),
Max(journal_entry_account.account_currency).as_("account_currency"),
)
.where(
(journal_entry_account.account == account)
@@ -215,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
journal_entry_query = journal_entry_query.where(
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
(journal_entry.clearance_date.isnull())
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
journal_entries = (
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
.orderby(journal_entry.posting_date)
.orderby(Max(journal_entry.posting_date))
.orderby(journal_entry.name, order=Order.desc)
).run(as_dict=True)
@@ -290,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
payment_entry_query = payment_entry_query.where(
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
(pe.clearance_date.isnull())
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
@@ -327,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
(pi.clearance_date.isnull())
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
paid_purchase_invoices = (
@@ -367,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
pos_sales_invoices_query = pos_sales_invoices_query.where(
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
(si_payment.clearance_date.isnull())
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
pos_sales_invoices = (

View File

@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -50,8 +51,7 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType",
"read_only": 1
"options": "DocType"
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.bg_type == \"Providing\"",
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -218,10 +218,11 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2025-08-29 11:52:33.550847",
"modified": "2026-05-25 18:12:10.768835",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
function toggle_distribution_fields(frm) {
const grid = frm.fields_dict.budget_distribution.grid;
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
["amount", "percent"].forEach((field) => {
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
});

View File

@@ -5,9 +5,11 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from frappe.utils.data import get_first_day
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -115,23 +117,26 @@ class Budget(Document):
if not account:
return
existing_budget = frappe.db.sql(
f"""
SELECT name, account
FROM `tabBudget`
WHERE
docstatus < 2
AND company = %s
AND {budget_against_field} = %s
AND account = %s
AND name != %s
AND (
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
)
""",
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
as_dict=True,
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
existing_budget = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name, budget.account)
.where(
(budget.docstatus < 2)
& (budget.company == self.company)
& (budget[budget_against_field] == budget_against)
& (budget.account == account)
& (budget.name != self.name)
& (fy_from.year_start_date <= self.budget_end_date)
& (fy_to.year_end_date >= self.budget_start_date)
)
.run(as_dict=True)
)
if existing_budget:
@@ -353,8 +358,8 @@ class Budget(Document):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
budget_exists = frappe.db.sql(
"""
select name
from `tabBudget`
where company = %s
and docstatus = 1
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
limit 1
""",
(params.company, year_end_date, year_start_date),
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
budget_exists = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name)
.where(
(budget.company == params.company)
& (budget.docstatus == 1)
& (fy_from.year_start_date <= year_end_date)
& (fy_to.year_end_date >= year_start_date)
)
.limit(1)
.run()
)
if not budget_exists:
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
if frappe.get_cached_value("DocType", doctype, "is_tree"):
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
params.is_tree = True
else:
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
params.is_tree = False
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
params.budget_against_field = budget_against
params.budget_against_doctype = doctype
budget_records = frappe.db.sql(
f"""
SELECT
b = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(b)
.select(
b.name,
b.{budget_against} AS budget_against,
getattr(b, budget_against).as_("budget_against"),
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date,
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
b.action_if_annual_budget_exceeded,
b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr,
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po,
b.action_if_accumulated_monthly_budget_exceeded_on_po
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
AND b.account = %s
{condition}
""",
(params.company, params.posting_date, params.account),
as_dict=True,
) # nosec
b.action_if_accumulated_monthly_budget_exceeded_on_po,
)
.where(b.company == params.company)
.where(b.docstatus == 1)
.where(b.budget_start_date <= params.posting_date)
.where(b.budget_end_date >= params.posting_date)
.where(b.account == params.account)
)
if params.is_tree:
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
dim = frappe.qb.DocType(doctype)
query = query.where(
ExistsCriterion(
frappe.qb.from_(dim)
.select(dim.name)
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
)
)
else:
query = query.where(getattr(b, budget_against) == params.get(budget_against))
budget_records = query.run(as_dict=True)
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
@@ -674,15 +688,27 @@ def get_actions(params, budget):
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
item_code,
as_list=1,
child = frappe.qb.DocType("Material Request Item")
parent = frappe.qb.DocType("Material Request")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
)
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.stock_qty > child.ordered_qty)
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
& (parent.material_request_type == "Purchase")
& (parent.status != "Stopped")
)
.run(as_list=1)
)
return data[0][0] if data else 0
@@ -690,37 +716,43 @@ def get_requested_amount(params):
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
and parent.status != 'Closed' and {condition}""",
item_code,
as_list=1,
child = frappe.qb.DocType("Purchase Order Item")
parent = frappe.qb.DocType("Purchase Order")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)
.run(as_list=1)
)
return data[0][0] if data else 0
def get_other_condition(params, for_doc):
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
def get_other_condition(params, child, parent, for_doc):
conditions = [child.expense_account == params.expense_account]
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
condition += (
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
)
conditions.append(child[budget_against_field] == params.get(budget_against_field))
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
conditions.append(parent[date_field][str(start_date) : str(end_date)])
return condition
return conditions
def get_actual_expense(params):
@@ -728,11 +760,19 @@ def get_actual_expense(params):
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
budget_against_field = params.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
gle = frappe.qb.DocType("GL Entry")
conditions = [
gle.is_cancelled == 0,
gle.account == params.get("account"),
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
gle.company == params.get("company"),
gle.docstatus == 1,
]
if params.get("month_end_date"):
conditions.append(gle.posting_date <= params.get("month_end_date"))
if params.is_tree:
lft_rgt = frappe.db.get_value(
@@ -740,35 +780,27 @@ def get_actual_expense(params):
)
params.update(lft_rgt)
condition2 = f"""
and exists(
select name from `tab{params.budget_against_doctype}`
where lft >= %(lft)s and rgt <= %(rgt)s
and name = gle.{budget_against_field}
tree = frappe.qb.DocType(params.budget_against_doctype)
conditions.append(
ExistsCriterion(
frappe.qb.from_(tree)
.select(tree.name)
.where(
(tree.lft >= params.get("lft"))
& (tree.rgt <= params.get("rgt"))
& (tree.name == gle[budget_against_field])
)
)
"""
)
else:
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
amount = flt(
frappe.db.sql(
f"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account = %(account)s
{condition1}
{date_condition}
and gle.company = %(company)s
and gle.docstatus = 1
{condition2}
""",
params,
)[0][0]
) # nosec
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where(Criterion.all(conditions))
.run()[0][0]
)
return amount

View File

@@ -18,6 +18,7 @@
"in_list_view": 1,
"label": "Start Date",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -25,26 +26,29 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "End Date",
"read_only": 1
"read_only": 1,
"reqd": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount"
"label": "Amount",
"reqd": 1
},
{
"fieldname": "percent",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percent"
"label": "Percent",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-03 13:18:28.398198",
"modified": "2026-06-18 11:23:17.669733",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",

View File

@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
from frappe.types import DF
amount: DF.Currency
end_date: DF.Date | None
end_date: DF.Date
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
percent: DF.Percent
start_date: DF.Date | None
start_date: DF.Date
# end: auto-generated types
pass

View File

@@ -75,7 +75,10 @@ def validate_company(company: str):
@frappe.whitelist()
def import_coa(file_name: str, company: str):
frappe.only_for("Accounts Manager")
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
@@ -453,6 +456,7 @@ def unset_existing_data(company):
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
frappe.db.set_value("Company", company, update_values, update_values)
# remove accounts data from various doctypes
@@ -464,8 +468,7 @@ def unset_existing_data(company):
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
dt = frappe.qb.DocType(doctype)
frappe.qb.from_(dt).where(dt.company == company).delete().run()
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
def set_default_accounts(company):

View File

@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
def check_if_child_exists(self):
return frappe.db.sql(
"select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2",
self.name,
return frappe.get_all(
"Cost Center",
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
pluck="name",
)
def if_allocation_exists_against_cost_center(self):

View File

@@ -72,10 +72,8 @@ class FiscalYear(Document):
if existing_fiscal_years:
for existing in existing_fiscal_years:
company_for_existing = frappe.db.sql_list(
"""select company from `tabFiscal Year Company`
where parent=%s""",
existing.name,
company_for_existing = frappe.get_all(
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
)
overlap = False

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.naming import set_name_from_naming_options
from frappe.query_builder.functions import Sum
from frappe.utils import create_batch, flt, fmt_money, now
import erpnext
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
if balance_must_be:
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
account,
gle = frappe.qb.DocType("GL Entry")
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where((gle.is_cancelled == 0) & (gle.account == account))
.run()
)[0][0]
if (balance_must_be == "Debit" and flt(balance) < 0) or (
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
def update_outstanding_amt(
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
):
gle = frappe.qb.DocType("GL Entry")
conditions = (
(gle.against_voucher_type == against_voucher_type)
& (gle.against_voucher == against_voucher)
& (gle.voucher_type != "Invoice Discounting")
)
if party_type and party:
party_condition = " and party_type={} and party={}".format(
frappe.db.escape(party_type), frappe.db.escape(party)
)
else:
party_condition = ""
conditions &= (gle.party_type == party_type) & (gle.party == party)
if against_voucher_type == "Sales Invoice":
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
conditions &= gle.account.isin([account, party_account])
else:
account_condition = f" and account = {frappe.db.escape(account)}"
conditions &= gle.account == account
# get final outstanding amt
bal = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and voucher_type != 'Invoice Discounting'
{party_condition} {account_condition}""",
(against_voucher_type, against_voucher),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(conditions)
.run()[0][0]
or 0.0
)
if against_voucher_type == "Purchase Invoice":
bal = -bal
elif against_voucher_type == "Journal Entry":
je_conditions = (
(gle.voucher_type == "Journal Entry")
& (gle.voucher_no == against_voucher)
& (gle.account == account)
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
)
if party_type and party:
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
against_voucher_amount = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
(against_voucher, account),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(je_conditions)
.run()[0][0]
)
if not against_voucher_amount:
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
oldname = doc.name
set_name_from_naming_options(autoname, doc)
newname = doc.name
frappe.db.sql(
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
dt = frappe.qb.DocType(doctype)
(
frappe.qb.update(dt)
.set(dt.name, newname)
.set(dt.to_rename, 0)
.set(dt.modified, now())
.where(dt.name == oldname)
).run()
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):

View File

@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
jv.flags.ignore_validate = True
jv.submit()
round_off_entry = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_no = %s
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
and debit = 0 and credit = '.01'""",
jv.name,
round_off_entry = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_no": jv.name,
"account": "_Test Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0,
"credit": 0.01,
},
pluck="name",
)
self.assertTrue(round_off_entry)
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
)
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
old_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
series = frappe.qb.DocType("Series")
old_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
rename_gle_sle_docs()
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
)
new_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
new_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)

View File

@@ -28,6 +28,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
from erpnext.accounts.party import get_party_account
from erpnext.accounts.services.gl_validator import validate_opening_entry_against_pcv
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
@@ -149,6 +150,9 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()

View File

@@ -39,28 +39,32 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
if not expiry_date:
expiry_date = today()
return frappe.db.sql(
"""
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s
and expiry_date>=%s and loyalty_points>0 and company=%s
order by expiry_date
""",
(customer, loyalty_program, expiry_date, company),
as_dict=1,
return frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"expiry_date": [">=", expiry_date],
"loyalty_points": [">", 0],
"company": company,
},
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
order_by="expiry_date",
)
def get_redemption_details(customer, loyalty_program, company):
return frappe._dict(
frappe.db.sql(
"""
select redeem_against, sum(loyalty_points)
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
group by redeem_against
""",
(customer, loyalty_program, company),
frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"loyalty_points": ["<", 0],
"company": company,
},
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
group_by="redeem_against",
as_list=True,
)
)

View File

@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += tax.base_tax_amount
included_taxes += flt(tax.base_tax_amount)
else:
included_taxes -= tax.base_tax_amount
included_taxes -= flt(tax.base_tax_amount)
return included_taxes

View File

@@ -1113,6 +1113,27 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(gl_entries, expected_gl_entries)
def test_payment_entry_with_inclusive_tax(self):
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
payment_entry = create_payment_entry(paid_amount=1180)
payment_entry.append(
"taxes",
{
"account_head": "_Test Account Service Tax - _TC",
"charge_type": "On Paid Amount",
"rate": 18,
"included_in_paid_amount": 1,
"add_deduct_tax": "Add",
"description": "Service Tax",
},
)
payment_entry.save()
payment_entry.submit()
# 1180 incl 18% => 1000 base + 180 tax
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()

View File

@@ -73,7 +73,10 @@ class PeriodClosingVoucher(AccountsController):
if not previous_fiscal_year:
return
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
# coerced to NULL but postgres rejects as an invalid date.
previous_fiscal_year_start_date = previous_fiscal_year[1]
previous_fiscal_year_closed = frappe.db.exists(
"Period Closing Voucher",
{
@@ -287,41 +290,44 @@ class PeriodClosingVoucher(AccountsController):
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
date_condition = ""
if only_opening_entries:
date_condition = "is_opening = 'Yes'"
else:
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
# nosemgrep
return frappe.db.sql(
"""
SELECT
name,
posting_date,
account,
account_currency,
debit_in_account_currency,
credit_in_account_currency,
debit,
credit,
{}
FROM `tabGL Entry`
WHERE
{}
AND company = %s
AND voucher_type != 'Period Closing Voucher'
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
AND is_cancelled = 0
""".format(
", ".join(self.accounting_dimension_fields),
date_condition,
),
(self.company, report_type),
as_dict=1,
as_iterator=as_iterator,
fields = [
gle.name,
gle.posting_date,
gle.account,
gle.account_currency,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.debit,
gle.credit,
]
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
query = (
frappe.qb.from_(gle)
.select(*fields)
.where(
(gle.company == self.company)
& (gle.voucher_type != "Period Closing Voucher")
& (gle.is_cancelled == 0)
& gle.account.isin(
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
)
)
)
if only_opening_entries:
query = query.where(gle.is_opening == "Yes")
else:
query = query.where(
gle.posting_date.between(self.period_start_date, self.period_end_date)
& (gle.is_opening == "No")
)
return query.run(as_dict=1, as_iterator=as_iterator)
def set_account_balance_dict(self, gle, acc_bal_dict):
key = self.get_key(gle)

View File

@@ -55,15 +55,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit"],
order_by="account",
as_list=True,
)
]
pcv.reload()
self.assertEqual(pcv.gle_processing_status, "Completed")
self.assertEqual(pcv_gle, expected_gle)
self.assertEqual(tuple(pcv_gle), expected_gle)
def test_cost_center_wise_posting(self):
surplus_account = create_account()
@@ -106,14 +110,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 200.0, 0.0, cost_center2),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, cost_center
from `tabGL Entry` where voucher_no=%s
order by account, cost_center
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "cost_center"],
order_by="account, cost_center",
as_list=True,
)
]
self.assertSequenceEqual(pcv_gle, expected_gle)
@@ -166,16 +172,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0, jv.finance_book),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, finance_book
from `tabGL Entry` where voucher_no=%s
order by account, finance_book
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "finance_book"],
order_by="account, finance_book",
as_list=True,
)
]
self.assertSequenceEqual(pcv_gle, expected_gle)
# compare order-independently: postgres and MariaDB order NULL finance_book differently
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
def test_gl_entries_restrictions(self):
cost_center = create_cost_center("Test Cost Center 1")
@@ -358,14 +367,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""
select sum(debit) as total_debit, sum(credit) as total_credit
from `tabGL Entry`
where voucher_type=%s and voucher_no=%s and is_cancelled=0
""",
("Journal Entry", jv.name),
as_dict=True,
totals_after_cancel = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)

View File

@@ -295,7 +295,7 @@ def get_payments(invoices):
.groupby(SalesInvoicePayment.mode_of_payment)
.select(
SalesInvoicePayment.mode_of_payment,
SalesInvoicePayment.account,
fn.Max(SalesInvoicePayment.account).as_("account"),
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
)
)
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
InvoiceDocType.account_for_change_amount,
InvoiceDocType.is_return,
InvoiceDocType.return_against,
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
ConstantColumn(invoice_doctype).as_("doctype"),
)
.where(
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
& (InvoiceDocType.is_pos == 1)
& (InvoiceDocType.pos_profile == pos_profile)
& (
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
)
)
)

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder.functions import IfNull, Sum
from frappe.query_builder.functions import IfNull, Lower, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
@@ -505,19 +505,20 @@ class POSInvoice(SalesInvoice):
if d.get("serial_no"):
serial_nos = get_serial_nos(d.serial_no)
for sr in serial_nos:
serial_no_exists = frappe.db.sql(
"""
SELECT name
FROM `tabPOS Invoice Item`
WHERE
parent = %s
and (serial_no = %s
or serial_no like %s
or serial_no like %s
or serial_no like %s
)
""",
(self.return_against, sr, sr + "\n%", "%\n" + sr, "%\n" + sr + "\n%"),
POI = frappe.qb.DocType("POS Invoice Item")
s = sr.lower()
serial_no_exists = (
frappe.qb.from_(POI)
.select(POI.name)
.where(POI.parent == self.return_against)
.where(
(Lower(POI.serial_no) == s)
| Lower(POI.serial_no).like(f"{s}\n%")
| Lower(POI.serial_no).like(f"%\n{s}")
| Lower(POI.serial_no).like(f"%\n{s}\n%")
)
.limit(1)
.run()
)
if not serial_no_exists:
@@ -963,15 +964,9 @@ def get_bundle_availability(bundle_item_code, warehouse):
def get_bin_qty(item_code, warehouse):
bin_qty = frappe.db.sql(
"""select actual_qty from `tabBin`
where item_code = %s and warehouse = %s
limit 1""",
(item_code, warehouse),
as_dict=1,
)
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
return bin_qty[0].actual_qty or 0 if bin_qty else 0
return actual_qty or 0
def get_pos_reserved_qty(item_code, warehouse):

View File

@@ -118,14 +118,21 @@ class POSProfile(Document):
def validate_default_profile(self):
for row in self.applicable_for_users:
res = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile User` pfu, `tabPOS Profile` pf
where
pf.name = pfu.parent and pfu.user = %s and pf.name != %s and pf.company = %s
and pfu.default=1 and pf.disabled = 0""",
(row.user, self.name, self.company),
pfu = frappe.qb.DocType("POS Profile User")
pf = frappe.qb.DocType("POS Profile")
res = (
frappe.qb.from_(pfu)
.inner_join(pf)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user == row.user)
& (pf.name != self.name)
& (pf.company == self.company)
& (pfu.default == 1)
& (pf.disabled == 0)
)
.run()
)
if row.default and res:
@@ -265,10 +272,11 @@ def get_permitted_nodes(group_type):
def get_child_nodes(group_type, root):
lft, rgt = frappe.db.get_value(group_type, root, ["lft", "rgt"])
return frappe.db.sql(
f""" Select name, lft, rgt from `tab{group_type}` where
lft >= {lft} and rgt <= {rgt} order by lft""",
as_dict=1,
return frappe.get_all(
group_type,
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
fields=["name", "lft", "rgt"],
order_by="lft",
)
@@ -278,40 +286,33 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.limit(page_len)
.offset(start)
.run()
)
if not pos_profile:
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
)
.run()
)
return pos_profile

View File

@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
if apply_on_field == "item_code":
if args.get("uom", None):
item_conditions += (
" and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
)
@@ -127,7 +127,7 @@ def _get_pricing_rules(apply_on, args, values):
elif apply_on_field == "item_group":
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
if args.get("uom", None):
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
item_conditions += " and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
@@ -139,7 +139,7 @@ def _get_pricing_rules(apply_on, args, values):
if not args.price_list:
args.price_list = None
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
conditions += " and coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
values["price_list"] = args.get("price_list")
pricing_rules = (
@@ -195,10 +195,8 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
except TypeError:
frappe.throw(_("Invalid {0}").format(args.get(field)))
parent_groups = frappe.db.sql_list(
"""select name from `tab{}`
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
(lft, rgt),
parent_groups = frappe.get_all(
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
)
if parenttype in ["Customer Group", "Item Group", "Territory"]:
@@ -217,14 +215,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
if parent_groups:
if allow_blank:
parent_groups.append("")
condition = "ifnull({table}.{field}, '') in ({parent_groups})".format(
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
table=table, field=field, parent_groups=", ".join(frappe.db.escape(d) for d in parent_groups)
)
frappe.flags.tree_conditions[key] = condition
elif allow_blank:
condition = f"ifnull({table}.{field}, '') = ''"
condition = f"coalesce({table}.{field}, '') = ''"
return condition
@@ -232,10 +230,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
def get_other_conditions(conditions, values, args):
for field in ["company", "customer", "supplier", "campaign", "sales_partner"]:
if args.get(field):
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
values[field] = args.get(field)
else:
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
@@ -248,8 +246,8 @@ def get_other_conditions(conditions, values, args):
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
)
if date:
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
conditions += """ and %(transaction_date)s between coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = date
if args.get("doctype") in [
@@ -264,9 +262,9 @@ def get_other_conditions(conditions, values, args):
"POS Invoice",
"POS Invoice Item",
]:
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
else:
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
return conditions

View File

@@ -431,7 +431,9 @@ def reconcile(doc: None | str = None) -> None:
# Update reconciled flag
allocation_names = [x.name for x in allocations]
ppa = qb.DocType("Process Payment Reconciliation Log Allocations")
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
qb.update(ppa).set(ppa.reconciled, 1).where(
ppa.name.isin(allocation_names)
).run() # smallint, not bool
# Update reconciled count
reconciled_count = frappe.db.count(

View File

@@ -16,6 +16,7 @@
"categorize_by",
"cost_center",
"territory",
"show_opening_entries",
"ignore_exchange_rate_revaluation_journals",
"ignore_cr_dr_notes",
"column_break_14",
@@ -414,10 +415,17 @@
"fieldtype": "Link",
"label": "Print Format",
"options": "Print Format"
},
{
"default": "0",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "show_opening_entries",
"fieldtype": "Check",
"label": "Show Opening Entries"
}
],
"links": [],
"modified": "2025-10-07 12:19:20.719898",
"modified": "2026-06-01 15:37:07.660442",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -6,7 +6,6 @@ import copy
import frappe
from frappe import _
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.utils import add_days, add_months, format_date, getdate, today
from frappe.utils.jinja import validate_template
@@ -20,6 +19,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
execute as get_ageing,
)
from erpnext.accounts.report.general_ledger.general_ledger import execute as get_soa
from erpnext.utilities.query import get_match_conditions_qb
class ProcessStatementOfAccounts(Document):
@@ -75,6 +75,7 @@ class ProcessStatementOfAccounts(Document):
sender: DF.Link | None
show_future_payments: DF.Check
show_net_values_in_party_account: DF.Check
show_opening_entries: DF.Check
show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
@@ -270,7 +271,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
"categorize_by": doc.categorize_by,
"currency": doc.currency,
"project": [p.project_name for p in doc.project],
"show_opening_entries": 0,
"show_opening_entries": doc.show_opening_entries,
"include_default_book_entries": 0,
"tax_id": tax_id if tax_id else None,
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
@@ -365,15 +366,19 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll
def get_customers_based_on_sales_person(sales_person):
lft, rgt = frappe.db.get_value("Sales Person", sales_person, ["lft", "rgt"])
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype = 'Customer'
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
""",
(lft, rgt),
as_dict=1,
steam = frappe.qb.DocType("Sales Team")
sp = frappe.qb.DocType("Sales Person")
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(
(steam.parenttype == "Customer")
& steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
.run(as_dict=1)
)
sales_person_records = frappe._dict()
for d in records:
@@ -468,31 +473,30 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
frappe.has_permission("Customer", "read", customer_name, throw=True)
billing_email = frappe.db.sql(
"""
SELECT
email.email_id
FROM
`tabContact Email` AS email
JOIN
`tabDynamic Link` AS link
ON
email.parent=link.parent
JOIN
`tabContact` AS contact
ON
contact.name=link.parent
WHERE
link.link_doctype='Customer'
and link.link_name=%s
and contact.is_billing_contact=1
{mcond}
ORDER BY
contact.creation desc
""".format(mcond=get_match_cond("Contact")),
customer_name,
email = frappe.qb.DocType("Contact Email")
link = frappe.qb.DocType("Dynamic Link")
contact = frappe.qb.DocType("Contact")
query = (
frappe.qb.from_(email)
.join(link)
.on(email.parent == link.parent)
.join(contact)
.on(contact.name == link.parent)
.select(email.email_id)
.where(
(link.link_doctype == "Customer")
& (link.link_name == customer_name)
& (contact.is_billing_contact == 1)
)
.orderby(contact.creation, order=frappe.qb.desc)
)
for condition in get_match_conditions_qb("Contact", table=contact):
query = query.where(condition)
billing_email = query.run()
if len(billing_email) == 0 or (billing_email[0][0] is None):
if billing_and_primary:
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))

View File

@@ -524,16 +524,11 @@ class PurchaseInvoice(BuyingController):
def check_prev_docstatus(self):
for d in self.get("items"):
if d.purchase_order:
submitted = frappe.db.sql(
"select name from `tabPurchase Order` where docstatus = 1 and name = %s", d.purchase_order
)
submitted = frappe.db.exists("Purchase Order", {"docstatus": 1, "name": d.purchase_order})
if not submitted:
frappe.throw(_("Purchase Order {0} is not submitted").format(d.purchase_order))
if d.purchase_receipt:
submitted = frappe.db.sql(
"select name from `tabPurchase Receipt` where docstatus = 1 and name = %s",
d.purchase_receipt,
)
submitted = frappe.db.exists("Purchase Receipt", {"docstatus": 1, "name": d.purchase_receipt})
if not submitted:
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
@@ -801,25 +796,20 @@ class PurchaseInvoice(BuyingController):
if cint(frappe.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")):
fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True)
pi = frappe.db.sql(
"""select name from `tabPurchase Invoice`
where
bill_no = %(bill_no)s
and supplier = %(supplier)s
and name != %(name)s
and docstatus < 2
and posting_date between %(year_start_date)s and %(year_end_date)s""",
{
pi = frappe.get_all(
"Purchase Invoice",
filters={
"bill_no": self.bill_no,
"supplier": self.supplier,
"name": self.name,
"year_start_date": fiscal_year.year_start_date,
"year_end_date": fiscal_year.year_end_date,
"name": ["!=", self.name],
"docstatus": ["<", 2],
"posting_date": ["between", [fiscal_year.year_start_date, fiscal_year.year_end_date]],
},
pluck="name",
)
if pi:
pi = pi[0][0]
pi = pi[0]
frappe.throw(
_("Supplier Invoice No exists in Purchase Invoice {0}").format(

View File

@@ -55,10 +55,13 @@ class ExpenseAccountService:
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
if item.purchase_receipt:
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
(item.purchase_receipt, stock_not_billed_account),
negative_expense_booked_in_pr = frappe.db.exists(
"GL Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": stock_not_billed_account,
},
)
if negative_expense_booked_in_pr:

View File

@@ -395,10 +395,14 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
):
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
if item.purchase_receipt and valuation_tax_accounts:
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
(item.purchase_receipt, valuation_tax_accounts),
negative_expense_booked_in_pr = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": ["in", valuation_tax_accounts],
},
pluck="name",
)
(

View File

@@ -11,6 +11,7 @@
"add_deduct_tax",
"charge_type",
"row_id",
"allocate_full_amount_to_stock_items",
"included_in_print_rate",
"included_in_paid_amount",
"col_break1",
@@ -78,6 +79,14 @@
"oldfieldname": "row_id",
"oldfieldtype": "Data"
},
{
"default": "1",
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
"fieldname": "allocate_full_amount_to_stock_items",
"fieldtype": "Check",
"label": "Allocate Full Amount to Stock Items"
},
{
"default": "0",
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",

View File

@@ -200,106 +200,11 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
set_purchase_references(target)
def update_details(source_doc, target_doc, source_parent):
def _validate_address_link(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
target_doc.inter_company_invoice_reference = source_doc.name
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _validate_address_link(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _validate_address_link(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"dispatch_address",
"dispatch_address_display",
source_doc.dispatch_address_name,
)
if source_doc.shipping_address_name and _validate_address_link(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"shipping_address",
"shipping_address_display",
source_doc.shipping_address_name,
)
if source_doc.customer_address and _validate_address_link(
source_doc.customer_address, "Company", details.get("company")
):
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
_apply_purchase_party_details(target_doc, source_doc, details)
else:
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _validate_address_link(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(
target_doc, "company_address", "company_address_display", source_doc.supplier_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
_apply_sales_party_details(target_doc, source_doc, details)
def update_item(source, target, source_parent):
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
@@ -378,6 +283,97 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
return doclist
def _get_linked_address(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
def _apply_purchase_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _get_linked_address(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _get_linked_address(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
)
if source_doc.shipping_address_name and _get_linked_address(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
)
if source_doc.customer_address and _get_linked_address(
source_doc.customer_address, "Company", details.get("company")
):
update_address(target_doc, "billing_address", "billing_address_display", source_doc.customer_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
def _apply_sales_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _get_linked_address(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(target_doc, "company_address", "company_address_display", source_doc.supplier_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"

View File

@@ -93,54 +93,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if enable_discount_accounting:
for item in doc.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
self._append_item_discount_gl_entries(item, gl_entries)
if (
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
@@ -159,81 +112,143 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
)
def _append_item_discount_gl_entries(self, item, gl_entries) -> None:
doc = self.doc
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
doc = self.doc
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
return
for item in doc.get("items"):
if not item.delivery_note and not item.dn_detail:
continue
booking = self._get_sdbnb_booking_for_item(item)
if booking:
self._append_sdbnb_gl_entries(item, booking, gl_entries)
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
def _get_sdbnb_booking_for_item(self, item) -> dict | None:
"""SDBNB account and valuation to reverse for a billed-from-delivery-note item, if any."""
if not item.delivery_note and not item.dn_detail:
return None
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
return None
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
dn_expense_account = frappe.get_cached_value("Delivery Note Item", item.dn_detail, "expense_account")
if not self._is_sdbnb_reversal(dn_expense_account, item):
return None
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
return None
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
return None
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
return {
"dn_expense_account": dn_expense_account,
"valuation_amount": valuation_rate * item.stock_qty,
}
def _is_sdbnb_reversal(self, dn_expense_account, item) -> bool:
"""True when the DN booked to an SDBNB account distinct from the item's expense account."""
return bool(
dn_expense_account
and frappe.get_cached_value("Account", dn_expense_account, "account_type")
== "Stock Delivered But Not Billed"
and item.expense_account
and dn_expense_account != item.expense_account
)
def _append_sdbnb_gl_entries(self, item, booking, gl_entries) -> None:
dn_expense_account = booking["dn_expense_account"]
valuation_amount = booking["valuation_amount"]
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
dn_account_currency,
item=item,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
def make_customer_gl_entry(self, gl_entries):
doc = self.doc
@@ -250,10 +265,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
if grand_total and not doc.is_internal_transfer():
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
# Did not use base_grand_total to book rounding loss gle
gl_entries.append(
self.get_gl_dict(
@@ -264,11 +275,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"due_date": doc.due_date,
"against": doc.against_income_account,
"debit": base_grand_total,
"debit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, base_grand_total, grand_total
),
"debit_in_transaction_currency": grand_total,
"against_voucher": against_voucher,
"against_voucher": self._resolve_against_voucher(),
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
@@ -296,10 +307,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": tax.account_head,
"against": doc.customer,
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
"credit_in_account_currency": (
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
if account_currency == doc.company_currency
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")),
flt(amount, tax.precision("tax_amount_after_discount_amount")),
),
"credit_in_transaction_currency": flt(
amount, tax.precision("tax_amount_after_discount_amount")
@@ -341,53 +352,57 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
for item in doc.get("items"):
if (
if not (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
continue
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
amount, base_amount = tax_service.get_amount_and_base_amount(
item, enable_discount_accounting
)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": (
flt(base_amount, item.precision("base_net_amount"))
if account_currency == doc.company_currency
else flt(amount, item.precision("net_amount"))
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
self._append_item_income_gl_entry(item, gl_entries, tax_service, enable_discount_accounting)
# expense account gl entries
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
gl_entries += super(SalesInvoice, doc).get_gl_entries()
def _append_item_income_gl_entry(self, item, gl_entries, tax_service, enable_discount_accounting) -> None:
doc = self.doc
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
amount, base_amount = tax_service.get_amount_and_base_amount(item, enable_discount_accounting)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, item.precision("base_net_amount")),
flt(amount, item.precision("net_amount")),
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
doc = self.doc
asset = frappe.get_cached_doc("Asset", item.asset)
@@ -461,10 +476,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.change_amount)
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
if payment_mode.base_amount:
# POS, make payment entries
gl_entries.append(
@@ -475,11 +486,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": payment_mode.account,
"credit": payment_mode.base_amount,
"credit_in_account_currency": payment_mode.base_amount
if doc.party_account_currency == doc.company_currency
else payment_mode.amount,
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
),
"credit_in_transaction_currency": payment_mode.amount,
"against_voucher": against_voucher,
"against_voucher": self._resolve_against_voucher(),
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
@@ -495,9 +506,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": payment_mode.account,
"against": doc.customer,
"debit": payment_mode.base_amount,
"debit_in_account_currency": payment_mode.base_amount
if payment_mode_account_currency == doc.company_currency
else payment_mode.amount,
"debit_in_account_currency": self._get_amount_in_account_currency(
payment_mode_account_currency,
payment_mode.base_amount,
payment_mode.amount,
),
"debit_in_transaction_currency": payment_mode.amount,
"cost_center": doc.cost_center,
},
@@ -525,9 +538,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.account_for_change_amount,
"debit": flt(doc.base_change_amount),
"debit_in_account_currency": flt(doc.base_change_amount)
if doc.party_account_currency == doc.company_currency
else flt(doc.change_amount),
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, flt(doc.base_change_amount), flt(doc.change_amount)
),
"debit_in_transaction_currency": flt(doc.change_amount),
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
@@ -570,10 +583,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.write_off_account,
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"credit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if doc.party_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
),
"credit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -593,10 +606,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": doc.write_off_account,
"against": doc.customer,
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"debit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if write_off_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
"debit_in_account_currency": self._get_amount_in_account_currency(
write_off_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
),
"debit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -659,3 +672,14 @@ class SalesInvoiceGLComposer(BaseGLComposer):
item=doc,
)
)
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
"""Base amount when the account is in company currency, else the transaction amount."""
return base_amount if account_currency == self.doc.company_currency else transaction_amount
def _resolve_against_voucher(self) -> str:
"""Settle against the original invoice for returns not kept on their own outstanding."""
doc = self.doc
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
return doc.return_against
return doc.name

View File

@@ -4,7 +4,7 @@
"""POS helpers for Sales Invoice."""
import frappe
from frappe import _, msgprint
from frappe import _
from frappe.utils import cint, flt, get_link_to_form
@@ -13,106 +13,140 @@ class PartialPaymentValidationError(frappe.ValidationError):
class POSService:
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
"""Populate POS-profile fields on the invoice; return the profile or None."""
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | dict | None:
"""Populate POS-profile fields on the invoice; return the profile, {} or None."""
doc = self.doc
if cint(doc.is_pos) != 1:
return None
self._set_default_change_amount_account()
if not self._ensure_pos_profile():
return None
pos = frappe.get_doc("POS Profile", doc.pos_profile) if doc.pos_profile else {}
if pos:
self._apply_pos_profile(pos, for_validate)
return pos
def _set_default_change_amount_account(self) -> None:
doc = self.doc
if not doc.account_for_change_amount:
doc.account_for_change_amount = frappe.get_cached_value(
"Company", doc.company, "default_cash_account"
)
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
def _ensure_pos_profile(self) -> bool:
"""Auto-pick a POS Profile for the company; return False if none could be found."""
doc = self.doc
if doc.pos_profile or doc.flags.ignore_pos_profile:
return True
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return None
doc.pos_profile = pos_profile.get("name")
from erpnext.stock.get_item_details import get_pos_profile
pos = {}
if doc.pos_profile:
pos = frappe.get_doc("POS Profile", doc.pos_profile)
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return False
if pos:
if not for_validate:
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
doc.pos_profile = pos_profile.get("name")
return True
if not for_validate and not doc.customer:
doc.customer = pos.customer
def _apply_pos_profile(self, pos, for_validate: bool) -> None:
doc = self.doc
if not for_validate:
self._apply_editable_pos_defaults(pos)
if not for_validate:
doc.ignore_pricing_rule = pos.ignore_pricing_rule
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
self._copy_pos_profile_fields(pos, for_validate)
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
self._set_selling_price_list(pos)
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if not for_validate:
self._set_update_stock_from_profile(pos)
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
self._apply_pos_item_defaults(pos, for_validate)
self._set_terms_and_taxes(pos)
if not for_validate:
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _apply_editable_pos_defaults(self, pos) -> None:
"""Profile defaults the user may override; only applied outside validation."""
doc = self.doc
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
if not doc.customer:
doc.customer = pos.customer
doc.ignore_pricing_rule = pos.ignore_pricing_rule
for item in doc.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _copy_pos_profile_fields(self, pos, for_validate: bool) -> None:
doc = self.doc
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
def _set_selling_price_list(self, pos) -> None:
doc = self.doc
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
TaxService(doc).set_taxes()
def _set_update_stock_from_profile(self, pos) -> None:
doc = self.doc
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
return pos
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _set_terms_and_taxes(self, pos) -> None:
doc = self.doc
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
TaxService(doc).set_taxes()
def update_paid_amount(self) -> None:
doc = self.doc
@@ -144,6 +178,7 @@ class POSService:
doc.paid_amount = 0
def validate_pos_return(self) -> None:
"""Ensure POS return payments are not less than the (negative) invoice total."""
doc = self.doc
if doc.is_consolidated:
return
@@ -160,6 +195,7 @@ class POSService:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
def validate_pos(self) -> None:
"""On a POS return, paid amount plus write-off cannot exceed the grand total."""
doc = self.doc
if doc.is_return:
invoice_total = doc.rounded_total or doc.grand_total
@@ -180,6 +216,7 @@ class POSService:
self.validate_pos_opening_entry()
def validate_full_payment(self) -> None:
"""Block partial payment on a submitted POS invoice unless the profile allows it."""
doc = self.doc
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
@@ -196,6 +233,7 @@ class POSService:
)
def validate_pos_opening_entry(self) -> None:
"""Require exactly one current, open POS Opening Entry for the profile."""
doc = self.doc
opening_entries = frappe.get_all(
"POS Opening Entry",
@@ -281,38 +319,6 @@ class POSService:
if entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
def get_warehouse(self) -> str | None:
doc = self.doc
POSProfile = frappe.qb.DocType("POS Profile")
user_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(
(POSProfile.user == frappe.session["user"])
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
)
)
user_pos_profile = user_query.run()
warehouse = user_pos_profile[0][1] if user_pos_profile else None
if not warehouse:
global_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
)
global_pos_profile = global_query.run()
if global_pos_profile:
warehouse = global_pos_profile[0][1]
elif not user_pos_profile:
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
return warehouse
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
account = frappe.db.get_value(
@@ -369,61 +375,43 @@ def update_multi_mode_option(doc, pos_profile) -> None:
def get_all_mode_of_payments(doc) -> list:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
)
return query.run(as_dict=1)
"""All enabled modes of payment with their default accounts for the doc's company."""
query, mopa, mop = _enabled_mode_of_payment_query(doc.company)
return query.select(mopa.default_account, mopa.parent, mop.type.as_("type")).run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account,
ModeOfPaymentAccount.parent.as_("mop"),
ModeOfPayment.type.as_("type"),
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name.isin(mode_of_payments))
.groupby(ModeOfPayment.name)
"""Map each of the named modes of payment to its account info for the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
data = (
query.select(mopa.default_account, mopa.parent.as_("mop"), mop.type.as_("type"))
.where(mop.name.isin(mode_of_payments))
# group by all selected columns so postgres accepts it (one row per mode of payment)
.groupby(mopa.default_account, mopa.parent, mop.type)
.run(as_dict=1)
)
data = query.run(as_dict=1)
return {row.get("mop"): row for row in data}
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPayment)
.join(ModeOfPaymentAccount)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name == mode_of_payment)
"""Account info for a single mode of payment in the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
return (
query.select(mopa.default_account, mopa.parent, mop.type.as_("type"))
.where(mop.name == mode_of_payment)
.run(as_dict=1)
)
return query.run(as_dict=1)
def _enabled_mode_of_payment_query(company: str):
"""Base query joining enabled modes of payment to their accounts for a company."""
mopa = frappe.qb.DocType("Mode of Payment Account")
mop = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(mopa)
.join(mop)
.on(mopa.parent == mop.name)
.where(mopa.company == company)
.where(mop.enabled == 1)
)
return query, mopa, mop

View File

@@ -21,45 +21,52 @@ class StatusService:
doc.status = "Draft"
return
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
if not status:
if doc.docstatus == 2:
status = "Cancelled"
elif doc.docstatus == 1:
if doc.is_internal_transfer():
doc.status = "Internal Transfer"
elif is_overdue(doc, total):
doc.status = "Overdue"
elif 0 < outstanding_amount < total:
doc.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
doc.status = "Unpaid"
elif doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
doc.status = "Credit Note Issued"
elif doc.is_return == 1:
doc.status = "Return"
elif outstanding_amount <= 0:
doc.status = "Paid"
else:
doc.status = "Submitted"
if (
doc.status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
doc.status += " and Discounted"
doc.status = self._get_submitted_status()
else:
doc.status = "Draft"
if update:
doc.db_set("status", doc.status, update_modified=update_modified)
def _get_submitted_status(self) -> str:
"""Status of a submitted invoice, with the invoice-discounting suffix applied."""
doc = self.doc
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
status = self._get_payment_status(outstanding_amount, total)
if (
status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
status += " and Discounted"
return status
def _get_payment_status(self, outstanding_amount: float, total: float) -> str:
doc = self.doc
if doc.is_internal_transfer():
return "Internal Transfer"
if is_overdue(doc, total):
return "Overdue"
if 0 < outstanding_amount < total:
return "Partly Paid"
if outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
return "Unpaid"
if doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
return "Credit Note Issued"
if doc.is_return == 1:
return "Return"
if outstanding_amount <= 0:
return "Paid"
return "Submitted"
def set_indicator(self) -> None:
doc = self.doc
if doc.outstanding_amount < 0:

View File

@@ -99,23 +99,24 @@ class TimesheetBillingService:
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
doc = self.doc
for data in timesheet.time_logs:
if (
(doc.project and args.timesheet_detail == data.name)
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == doc.name
and args.timesheet_detail == data.name
)
or (
doc.is_return
and doc.return_against
and data.sales_invoice
and data.sales_invoice == doc.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
if args.timesheet_detail == data.name and self._should_set_sales_invoice(data, sales_invoice):
data.sales_invoice = sales_invoice
def _should_set_sales_invoice(self, time_log, sales_invoice: str | None) -> bool:
"""Whether this time log's sales-invoice link should be (re)set to sales_invoice."""
doc = self.doc
if doc.project:
return True
if not time_log.sales_invoice:
return True
if not sales_invoice and time_log.sales_invoice == doc.name:
# clearing the link on cancellation of this invoice
return True
# clearing the link on a return raised against the original invoice
return bool(
doc.is_return
and doc.return_against
and not sales_invoice
and time_log.sales_invoice == doc.return_against
)

View File

@@ -20,6 +20,12 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice,
)
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
from erpnext.accounts.doctype.sales_invoice.services.pos import (
POSService,
get_all_mode_of_payments,
get_mode_of_payment_info,
get_mode_of_payments_info,
)
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset
@@ -1346,6 +1352,101 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(pos.grand_total, 100.0)
self.assertEqual(pos.write_off_amount, 0)
def test_set_pos_fields_populates_invoice_from_profile(self):
terms = frappe.db.exists("Terms and Conditions", "_Test POS Terms")
if not terms:
terms = (
frappe.get_doc(
{
"doctype": "Terms and Conditions",
"title": "_Test POS Terms",
"terms": "POS terms and conditions",
"selling": 1,
}
)
.insert()
.name
)
profile = make_pos_profile()
profile.customer = "_Test Customer"
profile.tax_category = "_Test Tax Category 1"
profile.account_for_change_amount = "Cash - _TC"
profile.ignore_pricing_rule = 1
profile.update_stock = 1
profile.apply_discount_on = "Grand Total"
profile.tc_name = terms
profile.taxes_and_charges = "_Test Sales Taxes and Charges Template - _TC"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
si.taxes = []
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.customer, "_Test Customer")
self.assertEqual(si.tax_category, "_Test Tax Category 1")
self.assertEqual(si.ignore_pricing_rule, 1)
self.assertEqual(si.account_for_change_amount, "Cash - _TC")
self.assertEqual(si.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
self.assertEqual(si.apply_discount_on, "Grand Total")
self.assertEqual(si.update_stock, 1)
self.assertEqual(si.terms, "POS terms and conditions")
self.assertTrue(si.get("payments"))
self.assertTrue(si.get("taxes"))
def test_set_pos_fields_for_validate_preserves_existing_values(self):
profile = make_pos_profile()
profile.tax_category = "_Test Tax Category 1"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.apply_discount_on = "Net Total"
existing_customer = si.customer
POSService(si).set_pos_fields(for_validate=True)
# for_validate must not overwrite a field the user already set
self.assertEqual(si.apply_discount_on, "Net Total")
# for_validate skips mode-of-payment fetch and profile-driven customer/tax_category
self.assertFalse(si.get("payments"))
self.assertEqual(si.customer, existing_customer)
self.assertFalse(si.tax_category)
def test_set_pos_fields_uses_profile_price_list_without_customer(self):
profile = make_pos_profile(selling_price_list="_Test Price List")
profile.customer = None
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.selling_price_list, "_Test Price List")
def test_pos_service_mode_of_payment_queries(self):
make_pos_profile() # ensures a Cash mode-of-payment account for _Test Company
si = create_sales_invoice(do_not_save=True)
single = get_mode_of_payment_info("Cash", "_Test Company")
self.assertTrue(single)
self.assertEqual(single[0].parent, "Cash")
all_modes = get_all_mode_of_payments(si)
self.assertTrue(any(row.parent == "Cash" for row in all_modes))
grouped = get_mode_of_payments_info(["Cash"], "_Test Company")
self.assertIn("Cash", grouped)
self.assertEqual(grouped["Cash"].mop, "Cash")
def test_auto_write_off_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -1476,6 +1577,75 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test Company with perpetual inventory"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=5,
rate=100,
)
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
si = make_sales_invoice(dn.name)
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)
self.assertEqual(sdbnb_credit, cogs_debit)
def test_get_gle_for_change_amount(self):
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.party_account_currency = "INR"
# no change amount -> no entries
si.change_amount = 0
self.assertEqual(SalesInvoiceGLComposer(si).get_gle_for_change_amount(), [])
# change amount without an account -> mandatory error
si.change_amount = 10
si.base_change_amount = 10
si.account_for_change_amount = None
self.assertRaises(frappe.ValidationError, SalesInvoiceGLComposer(si).get_gle_for_change_amount)
# change amount with an account -> debit-to debited, change account credited
si.account_for_change_amount = "Cash - _TC"
entries = SalesInvoiceGLComposer(si).get_gle_for_change_amount()
self.assertEqual(len(entries), 2)
debit_entry = next(entry for entry in entries if entry["account"] == si.debit_to)
credit_entry = next(entry for entry in entries if entry["account"] == "Cash - _TC")
self.assertEqual(debit_entry["party"], si.customer)
self.assertEqual(flt(debit_entry["debit"]), 10.0)
self.assertEqual(flt(credit_entry["credit"]), 10.0)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount
@@ -3710,6 +3880,27 @@ class TestSalesInvoice(ERPNextTestSuite):
party_link.delete()
def test_status_indicator(self):
from erpnext.accounts.doctype.sales_invoice.services.status import StatusService
si = create_sales_invoice(do_not_save=True)
cases = [
# outstanding, due_date, is_return -> indicator color, title
(-50, nowdate(), 0, "gray", "Credit Note Issued"),
(100, add_days(nowdate(), 5), 0, "orange", "Unpaid"),
(100, add_days(nowdate(), -5), 0, "red", "Overdue"),
(0, nowdate(), 1, "gray", "Return"),
(0, nowdate(), 0, "green", "Paid"),
]
for outstanding, due_date, is_return, color, title in cases:
with self.subTest(title=title):
si.outstanding_amount = outstanding
si.due_date = due_date
si.is_return = is_return
StatusService(si).set_indicator()
self.assertEqual(si.indicator_color, color)
self.assertEqual(si.indicator_title, title)
def test_payment_statuses(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry

View File

@@ -56,11 +56,14 @@ def valdiate_taxes_and_charges_template(doc):
# doc.is_default = 1
if doc.is_default == 1:
frappe.db.sql(
f"""update `tab{doc.doctype}` set is_default = 0
where is_default = 1 and name != %s and company = %s""",
(doc.name, doc.company),
)
template = frappe.qb.DocType(doc.doctype)
(
frappe.qb.update(template)
.set(template.is_default, 0)
.where(
(template.is_default == 1) & (template.name != doc.name) & (template.company == doc.company)
)
).run()
validate_disabled(doc)

View File

@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
)
if not immutable_ledger_enabled:
query = query.set(gle.is_cancelled, True)
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
query.run()
else:
@@ -683,12 +683,14 @@ def make_reverse_gl_entries(
if not all(gle_names):
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
else:
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where name in %s and is_cancelled = 0""",
(now(), frappe.session.user, tuple(gle_names)),
)
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
).run()
for entry in gl_entries:
new_gle = copy.deepcopy(entry)
@@ -725,9 +727,11 @@ def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
"""
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
(now(), frappe.session.user, voucher_type, voucher_no),
)
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
).run()

View File

@@ -900,16 +900,13 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
d.company, {"grand_total": d.grand_total, "base_grand_total": d.base_grand_total}
)
gle = frappe.qb.DocType("GL Entry")
company_wise_total_unpaid = frappe._dict(
frappe.db.sql(
"""
select company, sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where party_type = %s and party=%s
and is_cancelled = 0
group by company""",
(party_type, party),
)
frappe.qb.from_(gle)
.select(gle.company, Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where((gle.party_type == party_type) & (gle.party == party) & (gle.is_cancelled == 0))
.groupby(gle.company)
.run()
)
for d in companies:

View File

@@ -7,7 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Max, Substring, Sum
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -691,13 +691,11 @@ class ReceivablePayableReport:
.inner_join(jea)
.on(jea.parent == je.name)
.select(
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
Max(jea.reference_name).as_("invoice_no"),
Max(jea.party).as_("party"),
Max(jea.party_type).as_("party_type"),
Max(je.posting_date).as_("future_date"),
Max(je.cheque_no).as_("future_ref"),
jea.reference_name.as_("invoice_no"),
jea.party,
jea.party_type,
je.posting_date.as_("future_date"),
je.cheque_no.as_("future_ref"),
)
.where(
(je.docstatus < 2)
@@ -727,6 +725,14 @@ class ReceivablePayableReport:
future_amount.as_("future_amount"),
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
)
# One row per (future-payment JE, invoice, party): group by the JE name (primary key, so the
# JE-level posting_date/cheque_no are deterministic) plus the per-reference dimensions, summing
# amounts across JE Account rows that hit the same invoice. Without this GROUP BY the implicit
# single-group aggregate collapsed every future JE payment into one row keyed by an arbitrary
# invoice, mis-allocating the whole sum.
query = query.groupby(
je.name, jea.reference_name, jea.party, jea.party_type, je.posting_date, je.cheque_no
)
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
query = query.having(future_amount > 0)
return query.run(as_dict=True)

View File

@@ -699,6 +699,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
)
def test_future_payments_from_journal_entry(self):
# A single future-dated Journal Entry paying two different invoices must surface as one
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
si_a = self.create_sales_invoice(no_payment_schedule=True)
si_b = self.create_sales_invoice(no_payment_schedule=True)
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"voucher_type": "Journal Entry",
"company": self.company,
"posting_date": add_days(today(), 1),
"accounts": [
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_a.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_b.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
],
}
)
je.insert().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_future_payments": True,
}
report = execute(filters)[1]
rows_a = [row for row in report if row.voucher_no == si_a.name]
rows_b = [row for row in report if row.voucher_no == si_b.name]
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
# both into a single row and allocated the whole 100 to one arbitrary invoice
self.assertEqual(len(rows_a), 1)
self.assertEqual(len(rows_b), 1)
self.assertEqual(rows_a[0].future_amount, 50.0)
self.assertEqual(rows_b[0].future_amount, 50.0)
def test_sales_person(self):
sales_person = frappe.get_doc(
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}

View File

@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
budget_distributions = get_budget_distributions(budget)
for row in budget_distributions:
if not row.start_date or not row.end_date:
continue
months = get_months_in_range(row.start_date, row.end_date)
if not months:
continue
monthly_budget = flt(row.amount) / len(months)
for month_date in months:

View File

@@ -134,17 +134,17 @@ class TestGeneralLedger(ERPNextTestSuite):
revaluation_jv.submit()
# check the balance of the account
balance = frappe.db.sql(
"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where account = %s
group by account
""",
account.name,
balance = frappe.get_all(
"GL Entry",
filters={"account": account.name},
fields=[
{"SUM": "debit_in_account_currency", "as": "debit"},
{"SUM": "credit_in_account_currency", "as": "credit"},
],
group_by="account",
)
self.assertEqual(balance[0][0], 100)
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
# check if general ledger shows correct balance
columns, data = execute(

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cstr
@@ -100,178 +101,275 @@ def get_sales_payment_data(filters, columns):
return data
def get_conditions(filters):
conditions = "1=1"
def apply_conditions(query, a, filters):
"""Apply the same filters get_conditions() used to build, as parameterized qb .where() clauses.
`a` is the field source for the Sales Invoice columns -- either the `tabSales Invoice`
DocType or a subquery aliased `a` that selects those columns. This mirrors the previous
raw SQL where every predicate was keyed on the `a` alias.
"""
if filters.get("from_date"):
conditions += " and a.posting_date >= %(from_date)s"
query = query.where(a.posting_date >= filters.get("from_date"))
if filters.get("to_date"):
conditions += " and a.posting_date <= %(to_date)s"
query = query.where(a.posting_date <= filters.get("to_date"))
if filters.get("company"):
conditions += " and a.company=%(company)s"
query = query.where(a.company == filters.get("company"))
if filters.get("customer"):
conditions += " and a.customer = %(customer)s"
query = query.where(a.customer == filters.get("customer"))
if filters.get("owner"):
conditions += " and a.owner = %(owner)s"
query = query.where(a.owner == filters.get("owner"))
if filters.get("is_pos"):
conditions += " and a.is_pos = %(is_pos)s"
return conditions
query = query.where(a.is_pos == filters.get("is_pos"))
return query
def get_pos_invoice_data(filters):
conditions = get_conditions(filters)
result = frappe.db.sql(
""
"SELECT "
'posting_date, owner, sum(net_total) as "net_total", sum(total_taxes) as "total_taxes", '
'sum(paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount", '
"mode_of_payment, warehouse, cost_center "
"FROM ("
"SELECT "
'parent, item_code, sum(amount) as "base_total", warehouse, cost_center '
"from `tabSales Invoice Item` group by parent"
") t1 "
"left join "
"(select parent, mode_of_payment from `tabSales Invoice Payment` group by parent) t3 "
"on (t3.parent = t1.parent) "
"JOIN ("
"SELECT "
'docstatus, company, is_pos, name, posting_date, owner, sum(base_total) as "base_total", '
'sum(net_total) as "net_total", sum(total_taxes_and_charges) as "total_taxes", '
'sum(base_paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount" '
"FROM `tabSales Invoice` "
"GROUP BY name"
") a "
"ON ("
"t1.parent = a.name and t1.base_total = a.base_total) "
"WHERE a.docstatus = 1"
f" AND {conditions} "
"GROUP BY "
"owner, posting_date, warehouse",
filters,
as_dict=1,
sii = frappe.qb.DocType("Sales Invoice Item")
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
t1 = (
frappe.qb.from_(sii)
.select(
sii.parent,
Sum(sii.amount).as_("base_total"),
Max(sii.warehouse).as_("warehouse"),
Max(sii.cost_center).as_("cost_center"),
)
.groupby(sii.parent)
)
return result
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
t3 = (
frappe.qb.from_(sip)
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
.groupby(sip.parent)
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
a = (
frappe.qb.from_(si)
.select(
si.docstatus,
si.company,
si.customer,
si.is_pos,
si.name,
si.posting_date,
si.owner,
Sum(si.base_total).as_("base_total"),
Sum(si.net_total).as_("net_total"),
Sum(si.total_taxes_and_charges).as_("total_taxes"),
Sum(si.base_paid_amount).as_("paid_amount"),
Sum(si.outstanding_amount).as_("outstanding_amount"),
)
.groupby(si.name)
)
query = (
frappe.qb.from_(t1)
.left_join(t3)
.on(t3.parent == t1.parent)
.join(a)
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes).as_("total_taxes"),
Sum(a.paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
Max(t3.mode_of_payment).as_("mode_of_payment"),
t1.warehouse,
Max(t1.cost_center).as_("cost_center"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date, t1.warehouse)
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_sales_invoice_data(filters):
conditions = get_conditions(filters)
return frappe.db.sql(
f"""
select
a.posting_date, a.owner,
sum(a.net_total) as "net_total",
sum(a.total_taxes_and_charges) as "total_taxes",
sum(a.base_paid_amount) as "paid_amount",
sum(a.outstanding_amount) as "outstanding_amount"
from `tabSales Invoice` a
where a.docstatus = 1
and {conditions}
group by
a.owner, a.posting_date
""",
filters,
as_dict=1,
a = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(a)
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes_and_charges).as_("total_taxes"),
Sum(a.base_paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date)
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_mode_of_payments(filters):
mode_of_payments = {}
invoice_list = get_invoices(filters)
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
invoice_names = [invoice["name"] for invoice in invoice_list]
if invoice_list:
inv_mop = frappe.db.sql(
f"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
""",
as_dict=1,
# Branch 1: payments recorded directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(si1.owner, si1.posting_date, Coalesce(sip.mode_of_payment, "").as_("mode_of_payment"))
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
)
# Branch 2: payments via Payment Entry referencing the invoice
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(si2.owner, si2.posting_date, Coalesce(pe.mode_of_payment, "").as_("mode_of_payment"))
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
)
# Branch 3: payments via Journal Entry referencing the invoice
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.owner, je.posting_date, Coalesce(je.voucher_type, "").as_("mode_of_payment"))
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
)
# bare UNION => de-duplicated rows across the three branches
inv_mop = (branch1.union(branch2).union(branch3)).run(as_dict=True)
for d in inv_mop:
mode_of_payments.setdefault(d["owner"] + cstr(d["posting_date"]), []).append(d.mode_of_payment)
return mode_of_payments
def get_invoices(filters):
conditions = get_conditions(filters)
return frappe.db.sql(
f"""select a.name
from `tabSales Invoice` a
where a.docstatus = 1 and {conditions}""",
filters,
as_dict=1,
)
a = frappe.qb.DocType("Sales Invoice")
query = frappe.qb.from_(a).select(a.name).where(a.docstatus == 1)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_mode_of_payment_details(filters):
mode_of_payment_details = {}
invoice_list = get_invoices(filters)
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
invoice_names = [invoice["name"] for invoice in invoice_list]
if invoice_list:
inv_mop_detail = frappe.db.sql(
f"""
select t.owner,
t.posting_date,
t.mode_of_payment,
sum(t.paid_amount) as paid_amount
from (
select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner,a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(c.allocated_amount) as paid_amount
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
) t
group by t.owner, t.posting_date, t.mode_of_payment
""",
as_dict=1,
# Branch 1: amounts paid directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
mop1 = Coalesce(sip.mode_of_payment, "")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(
si1.owner,
si1.posting_date,
mop1.as_("mode_of_payment"),
Sum(sip.base_amount).as_("paid_amount"),
)
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
.groupby(si1.owner, si1.posting_date, mop1)
)
inv_change_amount = frappe.db.sql(
f"""select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.name in ({invoice_list_names})
and b.type = 'Cash'
and a.base_change_amount > 0
group by a.owner, a.posting_date, mode_of_payment""",
as_dict=1,
# Branch 2: amounts allocated via Payment Entry
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
mop2 = Coalesce(pe.mode_of_payment, "")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(
si2.owner,
si2.posting_date,
mop2.as_("mode_of_payment"),
Sum(per.allocated_amount).as_("paid_amount"),
)
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
.groupby(si2.owner, si2.posting_date, mop2)
)
# Branch 3: amounts credited via Journal Entry
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
mop3 = Coalesce(je.voucher_type, "")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(
je.owner, je.posting_date, mop3.as_("mode_of_payment"), Sum(jea.credit).as_("paid_amount")
)
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
.groupby(je.owner, je.posting_date, mop3)
)
# bare UNION => de-duplicated rows; wrapped as subquery `t` for the outer re-aggregation
t = branch1.union(branch2).union(branch3)
inv_mop_detail = (
frappe.qb.from_(t)
.select(
t.owner,
t.posting_date,
t.mode_of_payment,
Sum(t.paid_amount).as_("paid_amount"),
)
.groupby(t.owner, t.posting_date, t.mode_of_payment)
.run(as_dict=True)
)
# change amount paid back in cash, subtracted from the matching mode-of-payment detail below
sic = frappe.qb.DocType("Sales Invoice")
sipc = frappe.qb.DocType("Sales Invoice Payment")
mopc = Coalesce(sipc.mode_of_payment, "")
inv_change_amount = (
frappe.qb.from_(sic)
.join(sipc)
.on(sic.name == sipc.parent)
.select(
sic.owner,
sic.posting_date,
mopc.as_("mode_of_payment"),
Sum(sic.base_change_amount).as_("change_amount"),
)
.where(sic.name.isin(invoice_names))
.where(sipc.type == "Cash")
.where(sic.base_change_amount > 0)
.groupby(sic.owner, sic.posting_date, mopc)
.run(as_dict=True)
)
for d in inv_change_amount:

View File

@@ -2,12 +2,13 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from frappe.utils import flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import (
get_mode_of_payment_details,
get_mode_of_payments,
get_pos_invoice_data,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -102,6 +103,33 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
self.assertGreater(cc_init_amount, cc_final_amount)
def test_get_pos_invoice_data(self):
"""The POS path (is_pos filter -> get_pos_invoice_data) used nested loose-GROUP-BY subqueries
that raised on Postgres; it now aggregates deterministically and runs identically on both
engines."""
si = create_sales_invoice_record()
si.is_pos = 1
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 10000},
)
si.insert()
si.submit()
filters = frappe._dict(
{"is_pos": 1, "company": "_Test Company", "from_date": today(), "to_date": today()}
)
data = get_pos_invoice_data(filters)
# the POS invoice's paid amount is aggregated; previously this query raised GroupingError on PG
self.assertTrue(data)
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in data))
# customer filter must work: a.customer was not selected by the invoice subquery before the fix,
# so the filter errored on both engines. With the invoice's customer it still returns its payment.
filters["customer"] = si.customer
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in get_pos_invoice_data(filters)))
def get_filters():
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}

View File

@@ -37,25 +37,22 @@ def validate_disabled_accounts(gl_map):
def validate_accounting_period(gl_map):
accounting_periods = frappe.db.sql(
""" SELECT
ap.name as name, ap.exempted_role as exempted_role
FROM
`tabAccounting Period` ap, `tabClosed Document` cd
WHERE
ap.name = cd.parent
AND ap.company = %(company)s
AND ap.disabled = 0
AND cd.closed = 1
AND cd.document_type = %(voucher_type)s
AND %(date)s between ap.start_date and ap.end_date
""",
{
"date": gl_map[0].posting_date,
"company": gl_map[0].company,
"voucher_type": gl_map[0].voucher_type,
},
as_dict=1,
ap = frappe.qb.DocType("Accounting Period")
cd = frappe.qb.DocType("Closed Document")
accounting_periods = (
frappe.qb.from_(ap)
.inner_join(cd)
.on(ap.name == cd.parent)
.select(ap.name.as_("name"), ap.exempted_role.as_("exempted_role"))
.where(
(ap.company == gl_map[0].company)
& (ap.disabled == 0)
& (cd.closed == 1)
& (cd.document_type == gl_map[0].voucher_type)
& (ap.start_date <= gl_map[0].posting_date)
& (ap.end_date >= gl_map[0].posting_date)
)
.run(as_dict=1)
)
if accounting_periods:
@@ -81,13 +78,11 @@ def validate_cwip_accounts(gl_map):
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
)
if cwip_enabled:
cwip_accounts = [
d[0]
for d in frappe.db.sql(
"""select name from tabAccount
where account_type = 'Capital Work in Progress' and is_group=0"""
)
]
cwip_accounts = frappe.get_all(
"Account",
filters={"account_type": "Capital Work in Progress", "is_group": 0},
pluck="name",
)
for entry in gl_map:
if entry.account in cwip_accounts:
@@ -122,13 +117,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_("Opening Entry can not be created after Period Closing Voucher is created."),
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
)

View File

@@ -13,7 +13,7 @@ from frappe.desk.reportview import build_match_conditions
from frappe.model.meta import get_field_precision
from frappe.model.naming import determine_consecutive_week_number
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
add_days,
@@ -411,10 +411,9 @@ def get_count_on(account, fieldname, date):
else:
dr_or_cr = "debit" if fieldname == "invoiced_amount" else "credit"
cr_or_dr = "credit" if fieldname == "invoiced_amount" else "debit"
select_fields = (
"ifnull(sum(credit-debit),0)"
if fieldname == "invoiced_amount"
else "ifnull(sum(debit-credit),0)"
gl = frappe.qb.DocType("GL Entry")
amount_expr = (
Sum(gl.credit - gl.debit) if fieldname == "invoiced_amount" else Sum(gl.debit - gl.credit)
)
if (
@@ -422,14 +421,21 @@ def get_count_on(account, fieldname, date):
or (gle.against_voucher_type in ["Sales Order", "Purchase Order"])
or (gle.against_voucher == gle.voucher_no and gle.get(dr_or_cr) > 0)
):
payment_amount = frappe.db.sql(
f"""
SELECT {select_fields}
FROM `tabGL Entry` gle
WHERE docstatus < 2 and posting_date <= %(date)s and against_voucher = %(voucher_no)s
and party = %(party)s and name != %(name)s""",
{"date": date, "voucher_no": gle.voucher_no, "party": gle.party, "name": gle.name},
)[0][0]
payment_amount = (
(
frappe.qb.from_(gl)
.select(amount_expr)
.where(
(gl.docstatus < 2)
& (gl.posting_date <= date)
& (gl.against_voucher == gle.voucher_no)
& (gl.party == gle.party)
& (gl.name != gle.name)
)
.run()[0][0]
)
or 0
)
outstanding_amount = flt(gle.get(dr_or_cr)) - flt(gle.get(cr_or_dr)) - payment_amount
currency_precision = get_currency_precision() or 2
@@ -1169,26 +1175,27 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
def fix_total_debit_credit():
vouchers = frappe.db.sql(
"""select voucher_type, voucher_no,
sum(debit) - sum(credit) as diff
from `tabGL Entry`
group by voucher_type, voucher_no
having sum(debit) != sum(credit)""",
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
vouchers = (
frappe.qb.from_(gle)
.select(gle.voucher_type, gle.voucher_no, (Sum(gle.debit) - Sum(gle.credit)).as_("diff"))
.groupby(gle.voucher_type, gle.voucher_no)
.having(Sum(gle.debit) != Sum(gle.credit))
.run(as_dict=1)
)
for d in vouchers:
if abs(d.diff) > 0:
dr_or_cr = d.voucher_type == "Sales Invoice" and "credit" or "debit"
frappe.db.sql(
"""update `tabGL Entry` set {} = {} + {}
where voucher_type = {} and voucher_no = {} and {} > 0 limit 1""".format(
dr_or_cr, dr_or_cr, "%s", "%s", "%s", dr_or_cr
),
(d.diff, d.voucher_type, d.voucher_no),
gle = frappe.qb.DocType("GL Entry")
name = frappe.db.get_value(
"GL Entry",
{"voucher_type": d.voucher_type, "voucher_no": d.voucher_no, dr_or_cr: [">", 0]},
"name",
)
if name:
frappe.qb.update(gle).set(gle[dr_or_cr], gle[dr_or_cr] + d.diff).where(gle.name == name).run()
def get_currency_precision():
@@ -1230,11 +1237,12 @@ def get_held_invoices(party_type, party):
held_invoices = None
if party_type == "Supplier":
held_invoices = frappe.db.sql(
"select name from `tabPurchase Invoice` where on_hold = 1 and release_date IS NOT NULL and release_date > CURDATE()",
as_dict=1,
held_invoices = frappe.get_all(
"Purchase Invoice",
filters={"on_hold": 1, "release_date": [">", nowdate()]},
pluck="name",
)
held_invoices = set(d["name"] for d in held_invoices)
held_invoices = set(held_invoices)
return held_invoices
@@ -1742,13 +1750,15 @@ def sort_stock_vouchers_by_posting_date(
sle = frappe.qb.DocType("Stock Ledger Entry")
voucher_nos = [v[1] for v in stock_vouchers]
# only voucher_type/voucher_no are used downstream; order by Min() of the (per-voucher constant)
# posting_datetime so postgres accepts the GROUP BY without selecting non-aggregated columns
sles = (
frappe.qb.from_(sle)
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.select(sle.voucher_type, sle.voucher_no)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
.orderby(sle.posting_datetime)
.orderby(sle.creation)
.orderby(Min(sle.posting_datetime))
.orderby(Min(sle.creation))
)
if company:
@@ -1769,25 +1779,37 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
SLE = DocType("Stock Ledger Entry")
conditions = (SLE.posting_datetime >= posting_datetime) & (SLE.is_cancelled == 0)
if for_items:
conditions &= SLE.item_code.isin(for_items)
if for_warehouses:
conditions &= SLE.warehouse.isin(for_warehouses)
if company:
conditions &= SLE.company == company
# These SLE rows must stay locked for the duration of the repost so a concurrent stock
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
# the surrounding transaction ends, giving the same protection.
if frappe.db.db_type == "postgres":
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).
# posting_datetime is constant per voucher, so the ordering is unchanged vs the DISTINCT form.
query = (
frappe.qb.from_(SLE)
.select(SLE.voucher_type, SLE.voucher_no)
.distinct()
.where(SLE.posting_datetime >= posting_datetime)
.where(SLE.is_cancelled == 0)
.orderby(SLE.posting_datetime)
.orderby(SLE.creation)
.for_update()
.where(conditions)
.groupby(SLE.voucher_type, SLE.voucher_no)
.orderby(Min(SLE.posting_datetime))
.orderby(Min(SLE.creation))
)
if for_items:
query = query.where(SLE.item_code.isin(for_items))
if for_warehouses:
query = query.where(SLE.warehouse.isin(for_warehouses))
if company:
query = query.where(SLE.company == company)
# lock scanned rows on MariaDB; on postgres they were already locked above
if frappe.db.db_type != "postgres":
query = query.for_update()
future_stock_vouchers = query.run(as_dict=True)
@@ -1809,14 +1831,11 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
voucher_nos = [d[1] for d in future_stock_vouchers]
gles = frappe.db.sql(
"""
select name, account, credit, debit, cost_center, project, voucher_type, voucher_no
from `tabGL Entry`
where
posting_date >= {} and voucher_no in ({})""".format("%s", ", ".join(["%s"] * len(voucher_nos))),
tuple([posting_date, *voucher_nos]),
as_dict=1,
gles = frappe.get_all(
"GL Entry",
filters={"posting_date": [">=", posting_date], "voucher_no": ["in", voucher_nos]},
fields=["name", "account", "credit", "debit", "cost_center", "project", "voucher_type", "voucher_no"],
limit=0,
)
for d in gles:
@@ -2235,7 +2254,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
qb.update(ple)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.set(ple.delinked, True)
.set(ple.delinked, 1) # smallint column; postgres rejects boolean true
.where(
(ple.company == pl_entry.company)
& (ple.account_type == pl_entry.account_type)
@@ -2350,8 +2369,10 @@ class QueryPaymentLedger:
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.orderby(ple.invoice_date, ple.voucher_no)
.having(qb.Field("amount_in_account_currency") > 0)
# order by the select aliases (postgres can't ORDER BY a non-existent ple column)
.orderby(qb.Field("invoice_date"), qb.Field("voucher_no"))
# postgres HAVING can't reference a select alias; use the aggregate expression
.having(Sum(ple.amount_in_account_currency) > 0)
.limit(self.limit)
.run()
)
@@ -2365,18 +2386,21 @@ class QueryPaymentLedger:
query_voucher_amount = (
qb.from_(ple)
.select(
ple.account,
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
# every non-aggregated column to be grouped or aggregated)
Max(ple.account).as_("account"),
ple.voucher_type,
ple.voucher_no,
ple.party_type,
ple.party,
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
ple.cost_center.as_("cost_center"),
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Max(ple.cost_center).as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
ple.remarks,
Max(ple.remarks).as_("remarks"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
@@ -2390,14 +2414,15 @@ class QueryPaymentLedger:
query_voucher_outstanding = (
qb.from_(ple)
.select(
ple.account,
# Max() on columns constant per group keeps this valid on postgres (see above)
Max(ple.account).as_("account"),
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
ple.party_type,
ple.party,
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
)
@@ -2446,17 +2471,19 @@ class QueryPaymentLedger:
# build CTE filter
# only fetch invoices
# The combined CTE query has no GROUP BY, so these are row filters. MariaDB tolerates HAVING
# on a select alias here, but postgres does not; express them as WHERE on the source column.
if self.get_invoices:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") > 0
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency > 0
)
)
# only fetch payments
elif self.get_payments:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") < 0
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency < 0
)
)

View File

@@ -735,12 +735,16 @@ class Asset(AccountsController):
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
def cancel_movement_entries(self):
movements = frappe.db.sql(
"""SELECT asm.name, asm.docstatus
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
WHERE asm_item.parent=asm.name and asm_item.asset=%s and asm.docstatus=1""",
self.name,
as_dict=1,
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
movements = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm.name)
.where((asm_item.asset == self.name) & (asm.docstatus == 1))
.run(as_dict=True)
)
for movement in movements:
@@ -860,15 +864,18 @@ class Asset(AccountsController):
cwip_enabled = is_cwip_accounting_enabled(self.asset_category)
cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled)
query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s"""
if asset_bought_with_invoice:
# with invoice purchase either expense or cwip has been booked
expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1)
expense_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": fixed_asset_account}
)
if expense_booked:
# if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled
return False
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
cwip_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": cwip_account}
)
if cwip_booked:
# if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled
return True
@@ -878,10 +885,11 @@ class Asset(AccountsController):
# if cwip account isn't available do not make gl entries
return False
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
# if cwip is not booked from receipt then do not make gl entries
# if cwip is booked from receipt then make gl entries
return cwip_booked
return bool(
frappe.db.exists("GL Entry", {"voucher_no": purchase_document, "account": cwip_account})
)
def get_purchase_document(self):
asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value(
@@ -1074,11 +1082,15 @@ def make_post_gl_entry():
for asset_category in asset_categories:
if cint(asset_category.enable_cwip_accounting):
assets = frappe.db.sql_list(
""" select name from `tabAsset`
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
and available_for_use_date = %s and docstatus = 1""",
(asset_category.name, nowdate()),
assets = frappe.get_all(
"Asset",
filters={
"asset_category": asset_category.name,
"booked_fixed_asset": 0,
"available_for_use_date": nowdate(),
"docstatus": 1,
},
pluck="name",
)
for asset in assets:

View File

@@ -79,11 +79,14 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
"description": maintenance_task,
"date": next_due_date,
}
if not frappe.db.sql(
"""select owner from `tabToDo`
where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
and owner=%(assign_to)s""",
args,
if not frappe.db.exists(
"ToDo",
{
"reference_type": args["doctype"],
"reference_name": args["name"],
"status": "Open",
"owner": args["assign_to"],
},
):
# assign_to function expects a list
args["assign_to"] = [args["assign_to"]]
@@ -187,13 +190,9 @@ def get_team_members(
@frappe.whitelist()
def get_maintenance_log(asset_name: str):
return frappe.db.sql(
"""
select maintenance_status, count(asset_name) as count, asset_name
from `tabAsset Maintenance Log`
where asset_name=%s
group by maintenance_status
""",
(asset_name,),
as_dict=1,
return frappe.get_all(
"Asset Maintenance Log",
filters={"asset_name": asset_name},
fields=["maintenance_status", {"COUNT": "asset_name", "as": "count"}, "asset_name"],
group_by="maintenance_status, asset_name",
)

View File

@@ -18,6 +18,36 @@ class TestAssetMaintenance(ERPNextTestSuite):
self.asset_name = frappe.db.get_value("Asset", {"purchase_receipt": self.pr.name}, "name")
self.asset_doc = frappe.get_doc("Asset", self.asset_name)
def test_get_maintenance_log_counts_by_status(self):
"""get_maintenance_log uses a v16 dict aggregate field spec
({"COUNT": "asset_name", "as": "count"}); confirm it runs and returns correct per-status counts
on both engines (the whitelisted endpoint was previously untested)."""
from erpnext.assets.doctype.asset_maintenance.asset_maintenance import get_maintenance_log
self.asset_doc.available_for_use_date = nowdate()
self.asset_doc.purchase_date = nowdate()
self.asset_doc.save()
frappe.get_doc(
{
"doctype": "Asset Maintenance",
"asset_name": self.asset_name,
"maintenance_team": "Team Awesome",
"company": "_Test Company",
"asset_maintenance_tasks": get_maintenance_tasks(),
}
).insert()
rows = get_maintenance_log(self.asset_name)
# the dict aggregate spec did not crash and returned grouped rows...
self.assertTrue(rows)
self.assertTrue(all("maintenance_status" in r for r in rows))
# ...and the per-status counts sum to the total number of logs for this asset
self.assertEqual(
sum(r["count"] for r in rows),
frappe.db.count("Asset Maintenance Log", {"asset_name": self.asset_name}),
)
def test_create_asset_maintenance_with_log(self):
month_end_date = get_last_day(nowdate())

View File

@@ -127,24 +127,20 @@ class AssetMovement(Document):
def get_latest_location_and_custodian(self, asset):
current_location, current_employee = "", ""
cond = "1=1"
# latest entry corresponds to current document's location, employee when transaction date > previous dates
# In case of cancellation it corresponds to previous latest document's location, employee
args = {"asset": asset, "company": self.company}
latest_movement_entry = frappe.db.sql(
f"""
SELECT asm_item.target_location, asm_item.to_employee
FROM `tabAsset Movement Item` asm_item
JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name
WHERE
asm_item.asset = %(asset)s AND
asm.company = %(company)s AND
asm.docstatus = 1 AND {cond}
ORDER BY asm.transaction_date DESC
LIMIT 1
""",
args,
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
latest_movement_entry = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm_item.target_location, asm_item.to_employee)
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
.orderby(asm.transaction_date, order=frappe.qb.desc)
.limit(1)
.run()
)
if latest_movement_entry:

View File

@@ -215,17 +215,12 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
if parent is None or parent == "All Locations":
parent = ""
return frappe.db.sql(
f"""
select
name as value,
is_group as expandable
from
`tabLocation` comp
where
ifnull(parent_location, "")={frappe.db.escape(parent)}
""",
as_dict=1,
filters = {"parent_location": parent} if parent else {"parent_location": ["is", "not set"]}
return frappe.get_all(
"Location",
filters=filters,
fields=["name as value", "is_group as expandable"],
)

View File

@@ -395,32 +395,30 @@ def get_group_by_data(
def get_purchase_receipt_supplier_map():
pr = frappe.qb.DocType("Purchase Receipt")
pri = frappe.qb.DocType("Purchase Receipt Item")
return frappe._dict(
frappe.db.sql(
""" Select
pr.name, pr.supplier
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri
WHERE
pri.parent = pr.name
AND pri.is_fixed_asset=1
AND pr.docstatus=1
AND pr.is_return=0"""
)
frappe.qb.from_(pr)
.inner_join(pri)
.on(pri.parent == pr.name)
.select(pr.name, pr.supplier)
.distinct()
.where((pri.is_fixed_asset == 1) & (pr.docstatus == 1) & (pr.is_return == 0))
.run()
)
def get_purchase_invoice_supplier_map():
pi = frappe.qb.DocType("Purchase Invoice")
pii = frappe.qb.DocType("Purchase Invoice Item")
return frappe._dict(
frappe.db.sql(
""" Select
pi.name, pi.supplier
FROM `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pii
WHERE
pii.parent = pi.name
AND pii.is_fixed_asset=1
AND pi.docstatus=1
AND pi.is_return=0"""
)
frappe.qb.from_(pi)
.inner_join(pii)
.on(pii.parent == pi.name)
.select(pi.name, pi.supplier)
.distinct()
.where((pii.is_fixed_asset == 1) & (pi.docstatus == 1) & (pi.is_return == 0))
.run()
)

View File

@@ -0,0 +1,33 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
class TestFixedAssetRegister(AssetSetup):
def test_report_lists_submitted_asset(self):
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
asset = create_asset(
item_code="Macbook Pro",
purchase_date="2020-01-01",
available_for_use_date="2020-06-06",
location="Test Location",
submit=1,
)
filters = frappe._dict(
{
"company": "_Test Company",
"status": "In Location",
"filter_based_on": "Date Range",
"from_date": "2020-01-01",
"to_date": "2030-12-31",
"date_based_on": "Purchase Date",
}
)
data = execute(filters)[1]
asset_ids = {row.get("asset_id") for row in data}
self.assertIn(asset.name, asset_ids)

View File

@@ -43,6 +43,7 @@ def make_supplier_quotation_from_rfq(
"name": "request_for_quotation_item",
"parent": "request_for_quotation",
"project_name": "project",
"cost_center": "cost_center",
},
},
},
@@ -110,6 +111,7 @@ def create_rfq_items(sq_doc, supplier, data):
"material_request_item",
"stock_qty",
"uom",
"cost_center",
]:
args[field] = data.get(field)
@@ -176,6 +178,7 @@ def get_item_from_material_requests_based_on_supplier(
["name", "material_request_item"],
["parent", "material_request"],
["uom", "uom"],
["cost_center", "cost_center"],
],
},
},

View File

@@ -19,6 +19,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
from erpnext.tests.utils import ERPNextTestSuite
@@ -250,6 +251,41 @@ class TestRequestforQuotation(ERPNextTestSuite):
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
def test_cost_center_flows_from_mr_to_rfq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
self.assertEqual(rfq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_from_rfq_to_supplier_quotation(self):
rfq = make_request_for_quotation(do_not_submit=True)
rfq.items[0].cost_center = "_Test Cost Center - _TC"
rfq.save()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_end_to_end_mr_rfq_sq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
rfq.append("suppliers", {"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"})
rfq.insert()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier="_Test Supplier")
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def make_request_for_quotation(**args):
"""

View File

@@ -30,7 +30,9 @@
"col_break4",
"material_request",
"material_request_item",
"section_break_24",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project_name",
"section_break_23",
"page_break"
@@ -253,15 +255,26 @@
},
{
"collapsible": 1,
"fieldname": "section_break_24",
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"print_hide": 1
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-31 19:46:27.884592",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -413,39 +413,29 @@ class BuyingController(SubcontractingController):
stock_and_asset_items = []
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
last_item_idx = 1
for d in self.get("items"):
if d.item_code:
stock_and_asset_items_qty += flt(d.qty)
stock_and_asset_items_amount += flt(d.base_net_amount)
(
tax_accounts,
total_valuation_amount,
total_actual_tax_amount,
total_actual_tax_on_stock_items,
) = self.get_tax_details()
last_item_idx = d.idx
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row object).
actual_charge_per_item = self.distribute_actual_tax_amount(
stock_and_asset_items, total_actual_tax_amount, total_actual_tax_on_stock_items
)
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
remaining_amount = total_actual_tax_amount
last_item_idx = max((d.idx for d in self.get("items")), default=1)
for i, item in enumerate(self.get("items")):
if item.item_code and (item.qty or item.get("rejected_qty")):
item_tax_amount, actual_tax_amount = 0.0, 0.0
if i == (last_item_idx - 1):
# dump any rounding remainder of the On Net Total valuation on the last item
item_tax_amount = total_valuation_amount
actual_tax_amount = remaining_amount
else:
# calculate item tax amount
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
total_valuation_amount -= item_tax_amount
if total_actual_tax_amount:
actual_tax_amount = self.get_item_actual_tax_amount(
item,
total_actual_tax_amount,
stock_and_asset_items_amount,
stock_and_asset_items_qty,
)
remaining_amount -= actual_tax_amount
# This code is required here to calculate the correct valuation for stock items
if item.item_code not in stock_and_asset_items:
item.valuation_rate = 0.0
@@ -453,7 +443,8 @@ class BuyingController(SubcontractingController):
# Item tax amount is the total tax amount applied on that item and actual tax type amount
item.item_tax_amount = flt(
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
self.precision("item_tax_amount", item),
)
self.round_floats_in(item)
@@ -494,6 +485,7 @@ class BuyingController(SubcontractingController):
tax_accounts = []
total_valuation_amount = 0.0
total_actual_tax_amount = 0.0
total_actual_tax_on_stock_items = 0.0
for d in self.get("taxes"):
if d.category not in ["Valuation", "Valuation and Total"]:
@@ -506,10 +498,13 @@ class BuyingController(SubcontractingController):
if d.charge_type == "On Net Total":
total_valuation_amount += amount
tax_accounts.append(d.account_head)
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
# Allocate the full amount to stock/asset items only (e.g. Freight)
total_actual_tax_on_stock_items += amount
else:
total_actual_tax_amount += amount
return tax_accounts, total_valuation_amount, total_actual_tax_amount
return tax_accounts, total_valuation_amount, total_actual_tax_amount, total_actual_tax_on_stock_items
def get_item_tax_amount(self, item, tax_accounts):
item_tax_amount = 0.0
@@ -530,16 +525,75 @@ class BuyingController(SubcontractingController):
return item_tax_amount
def get_item_actual_tax_amount(
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
):
item_proportion = (
flt(item.base_net_amount) / stock_and_asset_items_amount
if stock_and_asset_items_amount
else flt(item.qty) / stock_and_asset_items_qty
def distribute_actual_tax_amount(self, stock_and_asset_items, total_on_all_items, total_on_stock_items):
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
`total_on_all_items` is spread across every item by net amount; a non-stock item's
share is computed but never capitalized (e.g. a genuine tax). `total_on_stock_items`
(flagged `allocate_full_amount_to_stock_items`) is spread across stock/asset items only,
so the whole charge is capitalized (e.g. Freight).
"""
all_items = [d for d in self.get("items") if d.item_code]
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
charge_per_item = {}
self._spread_charge_over_items(charge_per_item, total_on_all_items, all_items)
self._spread_charge_over_items(charge_per_item, total_on_stock_items, stock_items)
return charge_per_item
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
to the last item in the group."""
if not total_charge or not items:
return
total_amount = sum(flt(d.base_net_amount) for d in items)
total_qty = sum(flt(d.qty) for d in items)
# Nothing to proportion against (all rows have zero amount and zero qty)
if not total_amount and not total_qty:
return
remaining = total_charge
for d in items[:-1]:
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
remaining -= charge
last = items[-1]
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
remaining, self.precision("item_tax_amount", last)
)
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
def get_capitalized_valuation_tax(self):
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
all_items = [d for d in self.get("items") if d.item_code]
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
capitalized = {}
for tax in self.get("taxes"):
if tax.category not in ("Valuation", "Valuation and Total"):
continue
amount = flt(tax.base_tax_amount_after_discount_amount) * (
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
)
if not amount:
continue
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
# Spread across all items; only the stock/asset items' share is capitalized.
charge_per_item = {}
self._spread_charge_over_items(charge_per_item, amount, all_items)
amount = sum(
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
)
capitalized[tax.name] = amount
return capitalized
def set_incoming_rate(self):
"""

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.utils import cstr, flt
from erpnext.utilities.product import get_item_codes_by_attributes
@@ -135,6 +136,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
)
def get_attribute_value_renames(item_attribute):
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
if item_attribute.numeric_values:
return {}
db_value = item_attribute.get_doc_before_save()
if not db_value:
return {}
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
renames = {}
for row in item_attribute.item_attribute_values:
if row.name in old_values and old_values[row.name] != row.attribute_value:
renames[old_values[row.name]] = row.attribute_value
return renames
def update_variant_attribute_values(item_attribute):
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
value_map = get_attribute_value_renames(item_attribute)
if not value_map:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
item_table = frappe.qb.DocType("Item")
attribute_value = item_variant_table.attribute_value
attribute_value_case = Case()
for old_value, new_value in value_map.items():
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
(
frappe.qb.update(item_variant_table)
.join(item_table)
.on(item_table.name == item_variant_table.parent)
.set(attribute_value, attribute_value_case.else_(attribute_value))
.where(item_table.variant_of.isnotnull())
.where(item_table.variant_of != "")
.where(item_variant_table.attribute == item_attribute.name)
.where(attribute_value.isin(list(value_map)))
).run()
frappe.flags.attribute_values = None
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"

View File

@@ -10,7 +10,7 @@ from frappe import qb, scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.permissions import has_permission
from frappe.query_builder import Case, Criterion, DocType
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Substring, Sum
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Lower, Substring, Sum
from frappe.utils import nowdate, today, unique
from pypika import Order
@@ -313,11 +313,19 @@ def item_query(
.where(date_condition)
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, item.name) > 0, Locate(txt_no_percent, item.name)).else_(99999)
Case()
.when(
Locate(Lower(txt_no_percent), Lower(item.name)) > 0,
Locate(Lower(txt_no_percent), Lower(item.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, item.item_name) > 0, Locate(txt_no_percent, item.item_name))
.when(
Locate(Lower(txt_no_percent), Lower(item.item_name)) > 0,
Locate(Lower(txt_no_percent), Lower(item.item_name)),
)
.else_(99999)
)
.orderby(item.idx, order=Order.desc)
@@ -406,7 +414,13 @@ def get_project_name(
# ordering
if txt:
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
q = q.orderby(
ifelse(
Locate(Lower(txt), Lower(proj.project_name)) > 0,
Locate(Lower(txt), Lower(proj.project_name)),
99999,
)
)
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
if page_len:

View File

@@ -445,6 +445,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
doc.pricing_rules = []
doc.return_against = source.name
doc.set_warehouse = ""
if doctype == "Sales Invoice":
doc.is_debit_note = 0
if doctype == "Sales Invoice" or doctype == "POS Invoice":
doc.is_pos = source.is_pos

View File

@@ -744,7 +744,14 @@ class SubcontractingInwardController:
"name": ["in", list(data.keys())],
"docstatus": 1,
},
fields=["rate", "name", "required_qty", "received_qty"],
fields=[
"rate",
"name",
"required_qty",
"received_qty",
"returned_qty",
"consumed_qty",
],
)
doc_updates = {}
@@ -752,13 +759,17 @@ class SubcontractingInwardController:
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
current_rate = flt(data[d.name].rate)
# Calculate weighted average rate
old_total = d.rate * d.received_qty
# Weighted average rate must be computed on the on-hand balance
balance_qty = d.received_qty - d.returned_qty - d.consumed_qty
old_total = d.rate * balance_qty
current_total = current_rate * current_qty
new_balance_qty = balance_qty + current_qty
d.received_qty = d.received_qty + current_qty
d.rate = (
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
flt((old_total + current_total) / new_balance_qty, precision)
if new_balance_qty > 0
else 0.0
)
if not d.required_qty and not d.received_qty:

View File

@@ -290,13 +290,19 @@ class Opportunity(TransactionBase, CRMNote):
"name",
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where(
(q.docstatus == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Closed"])
)
.run()
)
def has_ordered_quotation(self):
@@ -305,24 +311,20 @@ class Opportunity(TransactionBase, CRMNote):
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where((q.docstatus == 1) & (qi.prevdoc_docname == self.name) & (q.status == "Ordered"))
.run()
)
def has_lost_quotation(self):
lost_quotation = frappe.db.sql(
"""
select name
from `tabQuotation`
where docstatus=1
and opportunity =%s and status = 'Lost'
""",
self.name,
lost_quotation = frappe.get_all(
"Quotation", filters={"docstatus": 1, "opportunity": self.name, "status": "Lost"}
)
if lost_quotation:
if self.has_active_quotation():
@@ -371,19 +373,19 @@ class Opportunity(TransactionBase, CRMNote):
@frappe.whitelist()
def get_item_details(item_code: str):
item = frappe.db.sql(
"""select item_name, stock_uom, image, description, item_group, brand
from `tabItem` where name = %s""",
item = frappe.db.get_value(
"Item",
item_code,
as_dict=1,
["item_name", "stock_uom", "image", "description", "item_group", "brand"],
as_dict=True,
)
return {
"item_name": item and item[0]["item_name"] or "",
"uom": item and item[0]["stock_uom"] or "",
"description": item and item[0]["description"] or "",
"image": item and item[0]["image"] or "",
"item_group": item and item[0]["item_group"] or "",
"brand": item and item[0]["brand"] or "",
"item_name": item and item.item_name or "",
"uom": item and item.stock_uom or "",
"description": item and item.description or "",
"image": item and item.image or "",
"item_group": item and item.item_group or "",
"brand": item and item.brand or "",
}

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.crm.doctype.utils import get_last_interaction
from erpnext.tests.utils import ERPNextTestSuite
class TestCrmDoctypeUtils(ERPNextTestSuite):
def test_get_last_interaction_for_contact(self):
"""Covers the converted Communication query (contact path): returns the earliest Received
communication across the doctypes the contact is linked to. `creation` is unique, so the
LIMIT-1 pick is deterministic and identical on MariaDB and Postgres."""
customer = "_Test CRM Util Customer"
if not frappe.db.exists("Customer", customer):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": customer,
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
}
).insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "CRM Util Test",
"links": [{"link_doctype": "Customer", "link_name": customer}],
}
).insert(ignore_permissions=True)
comm = frappe.get_doc(
{
"doctype": "Communication",
"subject": "hi",
"content": "first interaction",
"sent_or_received": "Received",
"reference_doctype": "Customer",
"reference_name": customer,
}
).insert(ignore_permissions=True)
result = get_last_interaction(contact=contact.name)
self.assertIsNotNone(result["last_communication"])
self.assertEqual(result["last_communication"]["name"], comm.name)

View File

@@ -1,4 +1,5 @@
import frappe
from frappe.query_builder import Criterion
@frappe.whitelist()
@@ -9,37 +10,33 @@ def get_last_interaction(contact: str | None = None, lead: str | None = None):
last_communication = None
last_issue = None
if contact:
query_condition = ""
values = []
communication = frappe.qb.DocType("Communication")
link_conditions = []
contact = frappe.get_doc("Contact", contact)
for link in contact.links:
if link.link_doctype == "Customer":
last_issue = get_last_issue_from_customer(link.link_name)
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
values += [link.link_doctype, link.link_name]
link_conditions.append(
(communication.reference_doctype == link.link_doctype)
& (communication.reference_name == link.link_name)
)
if query_condition:
# remove extra appended 'OR'
query_condition = query_condition[:-2]
last_communication = frappe.db.sql(
f"""
SELECT `name`, `content`
FROM `tabCommunication`
WHERE `sent_or_received`='Received'
AND ({query_condition})
ORDER BY `creation`
LIMIT 1
""",
values,
as_dict=1,
) # nosec
if link_conditions:
last_communication = (
frappe.qb.from_(communication)
.select(communication.name, communication.content)
.where((communication.sent_or_received == "Received") & Criterion.any(link_conditions))
.orderby(communication.creation)
.limit(1)
.run(as_dict=1)
)
if lead:
last_communication = frappe.get_all(
"Communication",
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
fields=["name", "content"],
order_by="`creation` DESC",
order_by="creation desc",
limit=1,
)
@@ -53,7 +50,7 @@ def get_last_issue_from_customer(customer_name):
"Issue",
{"customer": customer_name},
["name", "subject", "customer"],
order_by="`creation` DESC",
order_by="creation desc",
limit=1,
)

View File

@@ -4,7 +4,8 @@
import frappe
from frappe import _
from frappe.utils import flt
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt
def execute(filters=None):
@@ -30,17 +31,15 @@ def get_columns(based_on):
def get_lead_data(filters, based_on):
based_on_field = frappe.scrub(based_on)
conditions = get_filter_conditions(filters)
lead_details = frappe.db.sql(
f"""
select {based_on_field}, name
from `tabLead`
where {based_on_field} is not null and {based_on_field} != '' {conditions}
""",
filters,
as_dict=1,
)
lead_filters = [[based_on_field, "is", "set"]]
if filters.from_date:
lead_filters.append(["creation", ">=", filters.from_date])
if filters.to_date:
# date(creation) <= to_date, i.e. anything created before the next day
lead_filters.append(["creation", "<", add_days(filters.to_date, 1)])
lead_details = frappe.get_all("Lead", filters=lead_filters, fields=[based_on_field, "name"])
lead_map = frappe._dict()
for d in lead_details:
@@ -64,52 +63,36 @@ def get_lead_data(filters, based_on):
return data
def get_filter_conditions(filters):
conditions = ""
if filters.from_date:
conditions += " and date(creation) >= %(from_date)s"
if filters.to_date:
conditions += " and date(creation) <= %(to_date)s"
return conditions
def get_lead_quotation_count(leads):
return frappe.db.sql(
"""select count(name) from `tabQuotation`
where quotation_to = 'Lead' and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0] # nosec
return frappe.db.count("Quotation", {"quotation_to": "Lead", "party_name": ["in", leads]})
def get_lead_opp_count(leads):
return frappe.db.sql(
"""select count(name) from `tabOpportunity`
where opportunity_from = 'Lead' and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0]
return frappe.db.count("Opportunity", {"opportunity_from": "Lead", "party_name": ["in", leads]})
def get_quotation_ordered_count(leads):
return frappe.db.sql(
"""select count(name)
from `tabQuotation` where status = 'Ordered' and quotation_to = 'Lead'
and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0]
return frappe.db.count(
"Quotation", {"status": "Ordered", "quotation_to": "Lead", "party_name": ["in", leads]}
)
def get_order_amount(leads):
return frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Order Item`
where prevdoc_docname in (
select name from `tabQuotation` where status = 'Ordered'
and quotation_to = 'Lead' and party_name in (%s)
)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
so_item = frappe.qb.DocType("Sales Order Item")
quotation = frappe.qb.DocType("Quotation")
return (
frappe.qb.from_(so_item)
.select(Sum(so_item.base_net_amount))
.where(
so_item.prevdoc_docname.isin(
frappe.qb.from_(quotation)
.select(quotation.name)
.where(
(quotation.status == "Ordered")
& (quotation.quotation_to == "Lead")
& quotation.party_name.isin(leads)
)
)
)
.run()
)[0][0]

View File

@@ -0,0 +1,46 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.crm.report.campaign_efficiency.campaign_efficiency import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCampaignEfficiency(ERPNextTestSuite):
def test_lead_count_per_campaign(self):
"""execute() groups Leads by utm_campaign over a creation-date window and counts leads per
group. Seed two Leads sharing one distinct UTM Campaign, run the report over a window that
includes their (now-dated) creation, and assert that campaign's row reports lead_count == 2.
The group is unique to this test, so the count is exact rather than a tautology, and both
MariaDB and Postgres must return the same row/value."""
campaign = "_Test Campaign Eff Campaign"
if not frappe.db.exists("UTM Campaign", campaign):
frappe.get_doc({"doctype": "UTM Campaign", "__newname": campaign}).insert(ignore_permissions=True)
for i in range(2):
frappe.get_doc(
{
"doctype": "Lead",
"lead_name": f"_Test Campaign Eff Lead {i}",
"utm_campaign": campaign,
}
).insert(ignore_permissions=True)
# from_date <= creation(now) < to_date + 1 -> window covers the freshly inserted leads
filters = frappe._dict(
{
"from_date": add_days(nowdate(), -7),
"to_date": add_days(nowdate(), 1),
"based_on": "utm_campaign",
}
)
columns, data = execute(filters)
row = next((r for r in data if r.get("utm_campaign") == campaign), None)
self.assertIsNotNone(row, "campaign row missing from report output")
self.assertEqual(row["lead_count"], 2)
# no quotations/orders seeded for these leads -> derived counts are zero
self.assertEqual(row["quot_count"], 0)
self.assertEqual(row["order_count"], 0)

View File

@@ -4,6 +4,8 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Avg, Date
from pypika import Order
def execute(filters=None):
@@ -17,19 +19,19 @@ def execute(filters=None):
},
]
data = frappe.db.sql(
"""
SELECT
date(creation) as creation_date,
avg(first_response_time) as avg_response_time
FROM tabOpportunity
WHERE
date(creation) between %s and %s
and first_response_time > 0
GROUP BY creation_date
ORDER BY creation_date desc
""",
(filters.from_date, filters.to_date),
opportunity = frappe.qb.DocType("Opportunity")
creation_date = Date(opportunity.creation)
data = (
frappe.qb.from_(opportunity)
.select(
creation_date.as_("creation_date"), Avg(opportunity.first_response_time).as_("avg_response_time")
)
.where(
creation_date.between(filters.from_date, filters.to_date) & (opportunity.first_response_time > 0)
)
.groupby(creation_date)
.orderby(creation_date, order=Order.desc)
.run()
)
return columns, data

View File

@@ -0,0 +1,60 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, getdate, nowdate
from erpnext.crm.report.first_response_time_for_opportunity.first_response_time_for_opportunity import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestFirstResponseTimeForOpportunity(ERPNextTestSuite):
def test_avg_first_response_time_row(self):
"""The report groups Opportunity by Date(creation) and averages first_response_time where it
is > 0, between from_date and to_date. With a single seeded Opportunity created today and a
known first_response_time, the report must return a row for today whose averaged value equals
the seeded duration on both engines (Date(creation) and Avg via the query builder)."""
response_time = 3600 # seconds (Duration)
lead_email = "_test_frt_opp@example.com"
lead_name = "_Test FRT Opportunity Lead"
lead = frappe.db.exists("Lead", {"email_id": lead_email})
if not lead:
lead = (
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name, "email_id": lead_email})
.insert(ignore_permissions=True)
.name
)
opportunity = frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead,
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
}
).insert(ignore_permissions=True)
# first_response_time is a read-only computed field; set it directly.
frappe.db.set_value(
"Opportunity",
opportunity.name,
"first_response_time",
response_time,
update_modified=False,
)
columns, data = execute(
frappe._dict(from_date=add_days(nowdate(), -1), to_date=add_days(nowdate(), 1))
)
# rows are positional lists: [creation_date, avg_response_time]
today = getdate(nowdate())
row = next((r for r in data if getdate(r[0]) == today), None)
self.assertIsNotNone(row, "no report row for today's grouped creation date")
self.assertEqual(getdate(row[0]), today)
self.assertEqual(row[1], response_time)

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _, msgprint
from frappe.query_builder.functions import Count, Date
from frappe.utils import date_diff, flt
@@ -83,56 +84,51 @@ def get_communication_details(filters):
as_dict=1,
)
si = frappe.qb.DocType("Sales Invoice")
comm = frappe.qb.DocType("Communication")
for d in opportunities:
invoice = frappe.db.sql(
"""
SELECT
date(creation)
FROM
`tabSales Invoice`
WHERE
contact_email = %s AND date(creation) between %s and %s AND docstatus != 2
ORDER BY
creation
LIMIT 1
""",
(d.contact_email, filters.from_date, filters.to_date),
invoice = (
frappe.qb.from_(si)
.select(Date(si.creation))
.where(
(si.contact_email == d.contact_email)
& Date(si.creation).between(filters.from_date, filters.to_date)
& (si.docstatus != 2)
)
.orderby(si.creation)
.limit(1)
.run()
)
if not invoice:
continue
communication_count = frappe.db.sql(
"""
SELECT
count(*)
FROM
`tabCommunication`
WHERE
sender = %s AND date(communication_date) <= %s
""",
(d.contact_email, invoice),
invoice_date = invoice[0][0]
communication_count = (
frappe.qb.from_(comm)
.select(Count("*"))
.where((comm.sender == d.contact_email) & (Date(comm.communication_date) <= invoice_date))
.run()
)[0][0]
if not communication_count:
continue
first_contact = frappe.db.sql(
"""
SELECT
date(communication_date)
FROM
`tabCommunication`
WHERE
recipients = %s
ORDER BY
communication_date
LIMIT 1
""",
(d.contact_email),
)[0][0]
first_contact = (
frappe.qb.from_(comm)
.select(Date(comm.communication_date))
.where((comm.recipients == d.contact_email) & comm.communication_date.isnotnull())
.orderby(comm.communication_date)
.limit(1)
.run()
)
first_contact = first_contact[0][0] if first_contact else None
if not first_contact:
continue
duration = flt(date_diff(invoice[0][0], first_contact))
duration = flt(date_diff(invoice_date, first_contact))
support_tickets = len(frappe.db.get_all("Issue", {"raised_by": d.contact_email}))
communication_list.append(

View File

@@ -0,0 +1,59 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.crm.report.lead_conversion_time.lead_conversion_time import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestLeadConversionTime(ERPNextTestSuite):
def test_first_contact_ignores_null_communication_date(self):
"""first_contact ordered by the nullable communication_date and read row[0][0]. With no
IS NOT NULL guard, MariaDB (NULLs-first) returned a NULL-dated Communication -> first_contact
None -> a wrong duration, while Postgres (NULLs-last) returned the earliest real date. Filtering
communication_date IS NOT NULL (and guarding the slice) makes both engines use the earliest
real contact date."""
email = "_test_lead_conv@example.com"
customer_name = "_Test Lead Conv 22d"
lead = frappe.get_doc({"doctype": "Lead", "lead_name": customer_name, "email_id": email}).insert(
ignore_permissions=True
)
frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead.name,
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
"contact_email": email,
"customer_name": customer_name,
}
).insert(ignore_permissions=True)
si = create_sales_invoice(do_not_save=1)
si.contact_email = email
si.save() # draft (docstatus 0 != 2); Date(creation) is today, within range
# count query filters on `sender`; first_contact filters on `recipients` -> set both
real = frappe.get_doc(
{"doctype": "Communication", "subject": "real", "sender": email, "recipients": email}
).insert(ignore_permissions=True)
frappe.db.set_value(
"Communication", real.name, "communication_date", add_days(nowdate(), -22), update_modified=False
)
nulldate = frappe.get_doc(
{"doctype": "Communication", "subject": "nulldate", "sender": email, "recipients": email}
).insert(ignore_permissions=True)
frappe.db.set_value("Communication", nulldate.name, "communication_date", None, update_modified=False)
data = execute(frappe._dict({"from_date": add_days(nowdate(), -30), "to_date": nowdate()}))[1]
# rows are lists: [customer, interactions, duration, support_tickets]
row = next((r for r in data if r[0] == customer_name), None)
self.assertIsNotNone(row, "lead's converted-customer row missing")
# duration must be measured from the earliest REAL contact (22 days), not the NULL-dated one
self.assertEqual(row[2], 22.0)

View File

@@ -61,30 +61,47 @@ def get_columns():
def get_data(filters):
lead_details = []
lead_filters = get_lead_filters(filters)
leads = frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters)
if not leads:
return lead_details
for lead in frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters):
data = frappe.db.sql(
"""
select
`tabCommunication`.reference_doctype, `tabCommunication`.reference_name,
`tabCommunication`.content, `tabCommunication`.communication_date
from
(
(select name, party_name as lead from `tabOpportunity` where opportunity_from='Lead' and party_name = %(lead)s)
union
(select name, party_name as lead from `tabQuotation` where quotation_to = 'Lead' and party_name = %(lead)s)
union
(select name, lead from `tabIssue` where lead = %(lead)s and status!='Closed')
union
(select %(lead)s, %(lead)s)
)
as ref_document, `tabCommunication`
where
`tabCommunication`.reference_name = ref_document.name and
`tabCommunication`.sent_or_received = 'Received'
order by
ref_document.lead, `tabCommunication`.creation desc limit %(limit)s""",
{"lead": lead.name, "limit": filters.get("no_of_interaction")},
lead_names = [lead.name for lead in leads]
# Collect the documents (and the lead itself) that communications may reference, for all leads in
# three bulk queries instead of three per lead.
reference_names = {name: {name} for name in lead_names}
for opp in frappe.get_all(
"Opportunity",
filters={"opportunity_from": "Lead", "party_name": ["in", lead_names]},
fields=["name", "party_name"],
):
reference_names[opp.party_name].add(opp.name)
for quotation in frappe.get_all(
"Quotation",
filters={"quotation_to": "Lead", "party_name": ["in", lead_names]},
fields=["name", "party_name"],
):
reference_names[quotation.party_name].add(quotation.name)
for issue in frappe.get_all(
"Issue",
filters={"lead": ["in", lead_names], "status": ["!=", "Closed"]},
fields=["name", "lead"],
):
reference_names[issue.lead].add(issue.name)
for lead in leads:
data = frappe.get_all(
"Communication",
filters={
# constrain the doctype too: names are unique only within a doctype
"reference_doctype": ["in", ["Lead", "Opportunity", "Quotation", "Issue"]],
"reference_name": ["in", list(reference_names[lead.name])],
"sent_or_received": "Received",
},
fields=["reference_doctype", "reference_name", "content", "communication_date"],
order_by="creation desc",
limit=filters.get("no_of_interaction"),
as_list=True,
)
for lead_info in data:

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.crm.report.prospects_engaged_but_not_converted.prospects_engaged_but_not_converted import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProspectsEngagedButNotConverted(ERPNextTestSuite):
def test_lead_with_received_communications_appears(self):
"""The report lists non-converted Leads that have Communications referencing them
(reference_doctype="Lead", reference_name=lead.name) with sent_or_received="Received".
Seed one Lead and two such Received Communications, then assert the Lead surfaces in the
report data and that the emitted row carries the Lead -> reference_doctype/reference_name
linkage the get_data() join relies on. Asserting a concrete row (not a count) keeps this a
real-state smoke test that exercises the same path on both MariaDB and Postgres."""
lead_name = "_Test Prospect Engaged"
email = "_test_prospect_engaged@example.com"
lead = frappe.db.exists("Lead", {"lead_name": lead_name})
if lead:
lead = frappe.get_doc("Lead", lead)
else:
lead = frappe.get_doc(
{
"doctype": "Lead",
"lead_name": lead_name,
"email_id": email,
"company_name": "_Test Prospect Org",
}
).insert(ignore_permissions=True)
# A fresh, non-converted Lead is required for it to pass the report's lead filters.
self.assertNotEqual(lead.status, "Converted")
for subject in ("_test prospect engaged 1", "_test prospect engaged 2"):
if not frappe.db.exists(
"Communication",
{
"reference_doctype": "Lead",
"reference_name": lead.name,
"subject": subject,
},
):
frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"subject": subject,
"content": subject,
"sent_or_received": "Received",
"reference_doctype": "Lead",
"reference_name": lead.name,
}
).insert(ignore_permissions=True)
# filters are accessed via .get(...) in the report, so a plain _dict suffices
columns, data = execute(frappe._dict(no_of_interaction=1))
# rows are lists: [lead, lead_name, company_name, reference_doctype, reference_name, content, date]
row = next((r for r in data if r[0] == lead.name), None)
self.assertIsNotNone(row, "seeded Lead with Received communications missing from report data")
self.assertEqual(row[3], "Lead")
self.assertEqual(row[4], lead.name)
# content comes from one of the two seeded Received communications
self.assertIn(row[5], ("_test prospect engaged 1", "_test prospect engaged 2"))
# no_of_interaction=1 caps the per-lead communications to 1 -> exactly one row for this Lead
lead_rows = [r for r in data if r[0] == lead.name]
self.assertEqual(len(lead_rows), 1)

View File

@@ -596,6 +596,7 @@ accounting_dimension_doctypes = [
"Account Closing Balance",
"Supplier Quotation",
"Supplier Quotation Item",
"Request for Quotation Item",
"Payment Reconciliation",
"Payment Reconciliation Allocation",
"Payment Request",

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
"PO-Revision-Date: 2026-06-14 17:01\n"
"PO-Revision-Date: 2026-06-18 18:25\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Bosnian\n"
"MIME-Version: 1.0\n"
@@ -720,8 +720,8 @@ msgid "<h3>About Product Bundle</h3>\n\n"
"<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n"
"<h4>Example:</h4>\n"
"<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
msgstr "<h3>O Paketu Proizvoda</h3>\n\n"
"<p>Spoji grupu <b>artikala</b> u drugi <b>artikal</b>. Ovo je korisno ako spajate određene <b>Artikle</b> u paket i održavate zalihe upakiranih <b>artikala</b>, a ne zbirn <b>artikal</b>.</p>\n"
msgstr "<h3>O Paketu Artikala</h3>\n\n"
"<p>Spoji grupu <b>artikala</b> u drugi <b>artikal</b>. Ovo je korisno ako spajate određene <b>Artikle</b> u paket i održavate zalihe upakiranih <b>artikala</b>, a ne zbirni <b>artikal</b>.</p>\n"
"<p>Paketni <b>Artikal</b> će imati <code>artikle na zalihi</code> kao <b>Ne</b> i <code>Prodajni Artikal</code> kao <b>Da </b>.</p>\n"
"<h4>Primjer:</h4>\n"
"<p>Ako prodajete prijenosna računala i ruksake odvojeno i imate posebnu cijenu ako Klijent kupi oboje, tada će prijenosno računalo + ruksak biti novi artikal paketa proizvoda.</p>"
@@ -1102,7 +1102,7 @@ msgstr "Klijent mora imati primarni kontakt e-poštu."
#. Description of the 'Disabled' (Check) field in DocType 'Product Bundle'
#: erpnext/selling/doctype/product_bundle/product_bundle.json
msgid "A disabled Product Bundle cannot be selected in transactions."
msgstr ""
msgstr "Onemogućeni Paket Artikal ne može se odabrati u transakcijama."
#: erpnext/stock/doctype/delivery_trip/delivery_trip.py:59
msgid "A driver must be set to submit."
@@ -4487,7 +4487,7 @@ msgstr "Dozvoli Uređivanje Količine Jedinice Zaliha za Dokumente Prodaje"
#. DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Allow to edit stock UOM qty for Stock Entry"
msgstr ""
msgstr "Omogući uređivanje količine jedinice zaliha za Unos Zaliha"
#. Label of the allow_to_make_quality_inspection_after_purchase_or_delivery
#. (Check) field in DocType 'Stock Settings'
@@ -4597,7 +4597,7 @@ msgstr "Alternativni Artikal"
#: erpnext/stock/report/item_where_used/item_where_used.py:427
msgid "Alternative For Item"
msgstr ""
msgstr "Artikal Alternativa"
#. Label of the alternative_item_code (Link) field in DocType 'Item
#. Alternative'
@@ -6918,7 +6918,7 @@ msgstr "Alat Poređenja Sastavnica"
#: erpnext/stock/report/item_where_used/item_where_used.py:178
msgid "BOM Component"
msgstr ""
msgstr "Komponenta Sastavnice"
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
#: erpnext/manufacturing/doctype/bom/bom.json
@@ -6949,7 +6949,7 @@ msgstr "Artikal Sastavnice Konstruktora"
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
msgid "BOM Creator Item with name {0} does not exist"
msgstr ""
msgstr "Artikal Sastavnice s nazivom {0} ne postoji"
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
#. Supplied'
@@ -7049,7 +7049,7 @@ msgstr "Operativno Vrijeme Sastavnice"
#: erpnext/stock/report/item_where_used/item_where_used.py:248
msgid "BOM Output"
msgstr ""
msgstr "Sastavnica"
#: erpnext/stock/report/item_prices/item_prices.py:60
msgid "BOM Rate"
@@ -8222,13 +8222,13 @@ msgstr "Datum Fakture"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill Even If Previous Invoice Unpaid"
msgstr ""
msgstr "Fakturiši čak i ako prethodna faktura nije plaćena"
#. Option for the 'Generate Invoice At' (Select) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill N days before period start"
msgstr ""
msgstr "Fakturiši N dana prije početka perioda"
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
@@ -8410,13 +8410,13 @@ msgstr "e-pošta Fakture"
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Heatmap"
msgstr ""
msgstr "Toplinska mapa Fakturisanja"
#. Label of the billing_history_section (Section Break) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing History"
msgstr ""
msgstr "Historija Fakturisanja"
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
#. Timesheet'
@@ -8450,7 +8450,7 @@ msgstr "Faktura Interval u Planu pretplate mora biti Mjesec koji prati kalendars
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Period"
msgstr ""
msgstr "Period Fakturisanja"
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
@@ -9278,7 +9278,7 @@ msgstr "Izračunaj procijenjeno vrijeme dolaska"
#. Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Calculate Product Bundle price based on child Item's rates"
msgstr "Obračunaj Cijenu Paketa Proizvoda na osnovu cijena Podređenih Artikala"
msgstr "Obračunaj Cijenu Paketa Artikala na osnovu cijena Podređenih Artikala"
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
#. DocType 'Financial Report Row'
@@ -9547,7 +9547,7 @@ msgstr "Otkaži Pretplatu nakon perioda odgode"
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Cancel When Period Ends"
msgstr ""
msgstr "Otkaži kada se završi period"
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
@@ -9556,7 +9556,7 @@ msgstr "Datum Otkazivanja"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1567
msgid "Cancelled Job Card cannot be processed."
msgstr ""
msgstr "Otkazani Radni Nalog ne može se obraditi."
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:76
msgid "Cannot Assign Cashier"
@@ -9691,7 +9691,7 @@ msgstr "Nije moguće pretvoriti u Grupu jer je odabran Tip Računa."
#: erpnext/accounts/doctype/sales_invoice/mapper.py:372
msgid "Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. Please check the existing linked {2}s."
msgstr ""
msgstr "Nije moguće kreirati {0} između poduzeća. Svi početni artikli {1} su već u potpunosti fakturisani. Provjeri postojeće povezane {2}."
#: erpnext/stock/doctype/purchase_receipt/services/reservation.py:49
msgid "Cannot create Stock Reservation Entries for future dated Purchase Receipts."
@@ -9770,7 +9770,7 @@ msgstr "Nije moguće omogućiti račun zaliha po artiklima, jer postoje postoje
#: erpnext/crm/doctype/crm_settings/crm_settings.py:37
msgid "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
msgstr ""
msgstr "Nije moguće omogućiti kreiranje prilike iz kontakta jer je kontakt obrazac onemogućen."
#: erpnext/selling/doctype/sales_order/sales_order.py:624
#: erpnext/selling/doctype/sales_order/sales_order.py:647
@@ -9878,7 +9878,7 @@ msgstr "Nije moguće započeti brisanje. Drugo brisanje {0} je već u redu čeka
#: erpnext/manufacturing/doctype/job_card/job_card.py:922
msgid "Cannot submit Job Card {0} while it is On Hold. Please resume and complete the job before submission."
msgstr ""
msgstr "Nije moguće podnijeti Radni Nalog {0} dok je na čekanju. Nastavi i završi posao prije podnošenja."
#: erpnext/accounts/services/child_item_update.py:283
msgid "Cannot update rate as item {0} is already ordered or purchased against this quotation"
@@ -13410,7 +13410,7 @@ msgstr "Kreiraj novi trag"
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
msgid "Create New Version"
msgstr ""
msgstr "Kreiraj novu verziju"
#: banking/src/components/common/LinkFieldCombobox.tsx:284
msgid "Create New {0}"
@@ -14291,12 +14291,12 @@ msgstr "Trenutni Valuta kurs"
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice End"
msgstr ""
msgstr "Trenutni Završni Datum Fakture"
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice Start"
msgstr ""
msgstr "Trenutni Početni Datum Fakture"
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -17243,7 +17243,7 @@ msgstr "Onemogućeni Bankovni Račun"
#: erpnext/stock/doctype/packed_item/packed_item.py:216
msgid "Disabled Product Bundle"
msgstr ""
msgstr "Onemogući Paket Artikala"
#: erpnext/stock/utils.py:434
msgid "Disabled Warehouse {0} cannot be used for this transaction."
@@ -18939,7 +18939,7 @@ msgstr "Omogući Program Bodova Lojalnosti"
#. DocType 'CRM Settings'
#: erpnext/crm/doctype/crm_settings/crm_settings.json
msgid "Enable Opportunity Creation from Contact Us"
msgstr ""
msgstr "Omogući Kreiranje Prilika iz Kontaktiraj Nas obrasca"
#. Label of the enable_parallel_reposting (Check) field in DocType 'Stock
#. Reposting Settings'
@@ -19151,9 +19151,9 @@ msgid "Enabling this will do the following:\n"
msgstr "Omogućavanje ovoga će učiniti sljedeće:\n"
"<ul style=\"padding-left:16px\">\n"
"<li>Omogućiti uređivanje kolone cjene u svim tabelama Pakiranih/Paketnih artikala.</li>\n"
"<li>Izračunati cijene svih <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">paketa proizvoda</a> u tabeli artikala na osnovu cijena njihovih podređenih artikala navedenih u tabeli pakiranih/paketiranih artikala. </li>\n"
"<li>Izračunati cijene svih <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">paketa artikala</a> u tabeli artikala na osnovu cijena njihovih podređenih artikala navedenih u tabeli pakiranih/paketiranih artikala. </li>\n"
"</ul>\n"
"Napomena: Ako je ovo omogućeno, ažuriranje cjene proizvoda u paketu u tabeli artikala neće promijeniti njegovu cijenu. Cijena će se vratiti na cijenu zasnovanu na podređenim artiklima prilikom spremanja dokumenta."
"Napomena: Ako je ovo omogućeno, ažuriranje cjene artikala u paketu u tabeli artikala neće promijeniti njegovu cijenu. Cijena će se vratiti na cijenu zasnovanu na podređenim artiklima prilikom spremanja dokumenta."
#. Label of the encashment_date (Date) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -19541,7 +19541,7 @@ msgstr "Uloga Odobravatelja Izuzetka Proračuna"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:53
msgid "Excess Disassembly"
msgstr "Prekomjerna Demontaža"
msgstr "Prekomjerno Rastavljanje"
#: erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js:55
msgid "Excess Materials Consumed"
@@ -19977,7 +19977,7 @@ msgstr "Račun troškova je obavezan za artikal {0}"
#. Description of the 'Enable Deferred Revenue' (Check) field in DocType 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license"
msgstr "Trošak za ovaj artikal bit će priznat tokom nekoliko mjeseci. Npr: unaprijed plaćeno osiguranje ili godišnja licenca za softver"
msgstr "Trošak za ovaj artikal bit će priznat tokom nekoliko mjeseci. Npr: unaprijed plaćeno osiguranje ili godišnja licenca za program"
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:85
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:145
@@ -20225,7 +20225,7 @@ msgstr "Ažuriranje prioriteta pravila nije uspjelo"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:521
msgid "Failed to update subscription status for {0} {1}"
msgstr ""
msgstr "Nije uspjelo ažuriranje statusa pretplate za {0} {1}"
#. Label of the failure_date (Datetime) field in DocType 'Asset Repair'
#: erpnext/assets/doctype/asset_repair/asset_repair.json
@@ -20765,7 +20765,7 @@ msgstr "Gotov Proizvod {0} ne odgovara Radnom Nalogu {1}"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:71
msgid "Finished good quantity being consumed ({0} in stock UOM) must equal the quantity to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the finished good row."
msgstr ""
msgstr "Količina gotovog proizvoda koja se troši ({0} u jedinici zaliha) mora biti jednaka količini za rastavljanje ({1}). Ne mijenjaj jedinicu, faktor konverzije ili količinu u redu gotovog proizvoda."
#: erpnext/selling/doctype/sales_order/sales_order.js:615
msgid "First Delivery Date"
@@ -23435,13 +23435,13 @@ msgstr "Ako je odbrano, ovaj artikal se tretira kao direktna dostava u Prodajnim
#. Description of the 'Update Stock' (Check) field in DocType 'Sales Invoice'
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.json
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately."
msgstr ""
msgstr "Ako je oodabrano, ažurira inventar; zalihe i knjigovodstveni unosi se kreiraju zajedno. Ostavi neodabrano ako se Dostavnica kreira zasebno."
#. Description of the 'Update Stock' (Check) field in DocType 'Purchase
#. Invoice'
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately."
msgstr ""
msgstr "Ako je odabrano, ažurira se inventar; unosi zaliha i knjigoovodstva se kreiraju zajedno. Ostavi neodabrano ako Kupovni Račun kreira zasebno."
#: erpnext/public/js/setup_wizard.js:56
msgid "If checked, we will create demo data for you to explore the system. This demo data can be erased later."
@@ -25255,7 +25255,7 @@ msgstr "Nevažeće poduzeće za transakcije među poduzećima."
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:972
msgid "Invalid Configuration"
msgstr ""
msgstr "Nevažeća Konfiguracija"
#: erpnext/accounts/services/taxes.py:294
#: erpnext/assets/doctype/asset/asset.py:361
@@ -25273,12 +25273,12 @@ msgstr "Nevažeći Datum Dostave"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:110
msgid "Invalid Disassembly Item"
msgstr ""
msgstr "Nevažeći Artikala za Rastavljanje"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:76
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:125
msgid "Invalid Disassembly Quantity"
msgstr ""
msgstr "Nevažeća Količina za Rastavljanje"
#: erpnext/selling/page/point_of_sale/pos_item_cart.js:414
msgid "Invalid Discount"
@@ -26144,7 +26144,7 @@ msgstr "Je Fantomski Artikal"
#: erpnext/selling/doctype/sales_order_item/sales_order_item.json
#: erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
msgid "Is Product Bundle"
msgstr ""
msgstr "Je Paket Artikala"
#. Label of the po_required (Select) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -27652,7 +27652,7 @@ msgstr "Detalji Težine Artikla"
#. Name of a report
#: erpnext/stock/report/item_where_used/item_where_used.json
msgid "Item Where Used"
msgstr ""
msgstr "Gdje se koristi Artikal"
#. Label of a Link in the Buying Workspace
#. Name of a report
@@ -27775,7 +27775,7 @@ msgstr "Artikal {0} dodan je više puta pod isti nadređeni artikal {1} u redovi
#: erpnext/selling/doctype/product_bundle/product_bundle.js:54
msgid "Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}."
msgstr ""
msgstr "Artikal {0} već ima aktivni Paket Artikala ({1}). Podnošenjem ovoga kreiraće te novu verziju i deaktivirati {1}."
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:119
msgid "Item {0} cannot be added as a sub-assembly of itself"
@@ -28106,7 +28106,7 @@ msgstr "Stavka Radne Kartice"
#: erpnext/manufacturing/doctype/job_card/job_card.py:925
msgid "Job Card On Hold"
msgstr ""
msgstr "Radni Nalog je na čekanju"
#. Name of a DocType
#: erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json
@@ -30309,7 +30309,7 @@ msgstr "Usklađeno"
#: erpnext/stock/report/item_where_used/item_where_used.py:57
msgid "Matched Field"
msgstr ""
msgstr "Usklađeno polje"
#. Label of the matched_transaction_rule (Link) field in DocType 'Bank
#. Transaction'
@@ -30928,7 +30928,7 @@ msgstr "Metar/Sekunda"
#: erpnext/manufacturing/doctype/workstation/workstation.py:546
msgid "Method {0} is not allowed to be run on a Job Card."
msgstr ""
msgstr "Metoda {0} se ne smije izvršavati na Radnom Nalogu."
#. Name of a UOM
#: erpnext/setup/setup_wizard/data/uom_data.json
@@ -32258,13 +32258,13 @@ msgstr "Newton"
#. Label of the next_billing_period_end (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Next Billing Period End"
msgstr ""
msgstr "Sljedeći Perioda Fakturisanja Završava"
#. Label of the next_billing_period_start (Date) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Next Billing Period Start"
msgstr ""
msgstr "Sljedeći Perioda Fakturisanja Počinje"
#. Label of the next_depreciation_date (Date) field in DocType 'Asset'
#: erpnext/assets/doctype/asset/asset.json
@@ -33476,7 +33476,7 @@ msgstr "Samo jedna operacija može imati odabranu opciju 'Je li Gotov Proizvod'
#. Description of the 'Is Active' (Check) field in DocType 'Product Bundle'
#: erpnext/selling/doctype/product_bundle/product_bundle.json
msgid "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one."
msgstr ""
msgstr "Samo jedna verzija Paketa Artikala može biti aktivna u datom trenutku za dati Nadređeni Artikal. Aktiviranje verzije deaktivira prethodno aktivnu verziju."
#: erpnext/stock/doctype/stock_entry/stock_entry.py:719
msgid "Only one {0} entry can be created against the Work Order {1}"
@@ -39083,7 +39083,7 @@ msgstr "Vremenska oznaka knjiženja mora biti nakon {0}"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Postpaid (bill at period end)"
msgstr ""
msgstr "Naknadno Plaćeno (faktura na završetku perioda)"
#. Description of a DocType
#: erpnext/crm/doctype/opportunity/opportunity.json
@@ -39186,7 +39186,7 @@ msgstr "Preferirana e-pošta"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Prepaid (bill at period start)"
msgstr ""
msgstr "Unaprijed Plaćeno (faktura na početku perioda)"
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:34
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:51
@@ -40174,7 +40174,7 @@ msgstr "Stanje Paketa Proizvoda"
#: erpnext/stock/report/item_where_used/item_where_used.py:278
msgid "Product Bundle Component"
msgstr ""
msgstr "Komponenta Paketa Artikala"
#. Label of the product_bundle_help (HTML) field in DocType 'POS Invoice'
#. Label of the product_bundle_help (HTML) field in DocType 'Sales Invoice'
@@ -40195,11 +40195,11 @@ msgstr "Pomoć Paketa Proizvoda"
#: erpnext/selling/doctype/product_bundle_item/product_bundle_item.json
#: erpnext/stock/doctype/pick_list_item/pick_list_item.json
msgid "Product Bundle Item"
msgstr "Artikal Paketa Proizvoda"
msgstr "Artikal Paketa Artikala"
#: erpnext/stock/report/item_where_used/item_where_used.py:305
msgid "Product Bundle Parent"
msgstr ""
msgstr "Nadređeni Paket Artikala"
#. Description of the 'Product Bundle' (Link) field in DocType 'Purchase
#. Invoice Item'
@@ -40213,15 +40213,15 @@ msgstr ""
#: erpnext/stock/doctype/packed_item/packed_item.json
#: erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
msgid "Product Bundle version this row was packed from"
msgstr ""
msgstr "Verzija Paketa Artikala iz koje je ovaj red preuzet iz"
#: erpnext/stock/doctype/packed_item/packed_item.py:453
msgid "Product Bundle {0} is disabled and cannot be used in transactions."
msgstr ""
msgstr "Paket Artikala {0} je onemogućen i ne može se koristiti u transakcijama."
#: erpnext/stock/doctype/packed_item/packed_item.py:450
msgid "Product Bundle {0} is not submitted"
msgstr ""
msgstr "Paket Artikala {0} nije podnešen"
#. Label of the product_discount_scheme_section (Section Break) field in
#. DocType 'Pricing Rule'
@@ -43881,7 +43881,7 @@ msgstr "Osvježite Plaid Link"
#. Option for the 'Status' (Select) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Refunded"
msgstr ""
msgstr "Povraćeno"
#: erpnext/stock/reorder_item.py:390
msgid "Regards,"
@@ -43992,7 +43992,7 @@ msgstr "Povezano"
#: erpnext/stock/report/item_where_used/item_where_used.py:50
msgid "Related Item"
msgstr ""
msgstr "Povezani Artikal"
#. Label of the relation (Data) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -46064,7 +46064,7 @@ msgstr "Red #{0}: Gotov Proizvod artikla nije navedena zaservisni artikal {1}"
#: erpnext/manufacturing/doctype/bom/bom.py:371
msgid "Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
msgstr ""
msgstr "Red #{0}: Artikal Gotovog Proizvoda {1} ne može se dodati u tabelu Sekundarnih Artikala."
#: erpnext/buying/doctype/purchase_order/services/subcontracting.py:28
#: erpnext/selling/doctype/sales_order/services/subcontracting.py:27
@@ -46155,7 +46155,7 @@ msgstr "Red #{0}: Artikal {1} nije artikal na zalihama"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:106
msgid "Row #{0}: Item {1} is not part of the source manufacture entry and cannot be added to this disassembly."
msgstr ""
msgstr "Red #{0}: Artikal {1} nije dio unosa izvornog proizvođača i ne može se dodati ovom rastavljanju."
#: erpnext/controllers/subcontracting_inward_controller.py:79
msgid "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead."
@@ -46167,7 +46167,7 @@ msgstr "Red #{0}: Artikla {1} se ne slaže. Promjena koda artikla nije dozvoljen
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:115
msgid "Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity derived from the source ({3}). Do not change the UOM, conversion factor or quantity of disassembly rows."
msgstr ""
msgstr "Red #{0}: Količina artikla {1} ({2} u jedinici zaliha) ne odgovara količini izvedenoj iz izvora ({3}). Ne mijenjaj jedinicu, faktor konverzije ili količinu redova za rastavljanje."
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:780
msgid "Row #{0}: Journal Entry {1} does not have account {2} or already matched against another voucher"
@@ -46233,7 +46233,7 @@ msgstr "Red #{0}: Procentualni Gubitka Procesa treba da bude manji od 100% za {1
#: erpnext/stock/doctype/packed_item/packed_item.py:213
msgid "Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions."
msgstr ""
msgstr "Red #{0}: Paket Artikal {1} je onemogućen i ne može se koristiti u transakcijama."
#: erpnext/public/js/utils/barcode_scanner.js:425
msgid "Row #{0}: Qty increased by {1}"
@@ -49490,7 +49490,7 @@ msgstr "Serijski i Šaržni Paket {0} nije podnešen"
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:2251
msgid "Serial and Batch Bundle {0} is submitted and its entries cannot be modified."
msgstr ""
msgstr "Serijski i Šaržni Paket {0} je podnešen i njegovi unosi se ne mogu mijenjati."
#. Label of the section_break_45 (Section Break) field in DocType
#. 'Subcontracting Receipt Item'
@@ -52668,7 +52668,7 @@ msgstr "Podizvođačka Dostava"
#: erpnext/stock/report/item_where_used/item_where_used.py:362
msgid "Subcontracting Finished Good"
msgstr ""
msgstr "Podizvođački Gotov Proizvod"
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
#. Settings'
@@ -52852,7 +52852,7 @@ msgstr "Podizvođački Prodajni Nalog"
#: erpnext/stock/report/item_where_used/item_where_used.py:336
msgid "Subcontracting Service Item"
msgstr ""
msgstr "Podizvođački Uslužni Artikal"
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -52900,7 +52900,7 @@ msgstr "Podnesi Ponudu"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
msgid "Submitted Job Card cannot be processed."
msgstr ""
msgstr "Podnešeni Radni Nalog ne može biti obrađen."
#. Label of the subscription_section (Section Break) field in DocType 'Payment
#. Request'
@@ -55486,7 +55486,7 @@ msgstr "Otpremljena datoteka nije u važećem MT940 formatu."
#: erpnext/edi/doctype/code_list/code_list_import.py:40
msgid "The uploaded file does not match the selected Code List."
msgstr "Učitani fajl ne odgovara odabranoj Listi Kodova."
msgstr "Učitana datoteka ne odgovara odabranoj Listi Kodova."
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js:10
msgid "The user cannot submit the Serial and Batch Bundle manually"
@@ -61720,7 +61720,7 @@ msgstr "Ne možete podnijeti nalog bez plaćanja."
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:968
msgid "You cannot update stock for a Debit Note. A Debit Note is a financial document that should not affect inventory. Please disable 'Update Stock'."
msgstr ""
msgstr "Ne možete ažurirati zalihe za debitnu notu. Debitna nota je finansijski dokument koji ne bi trebao utjecati na zalihe. Molimo vas da onemogućite opciju 'Ažuriraj Zalihe'."
#: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py:106
msgid "You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
@@ -62086,7 +62086,7 @@ msgstr "izvodi bilo koje dolje:"
#. Item'
#: erpnext/stock/doctype/pick_list_item/pick_list_item.json
msgid "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle"
msgstr "naziv reda artikla paketa proizvoda u prodajnom nalogu. Također označava da odabrani artikal treba koristiti za paket proizvoda"
msgstr "naziv reda artikla paketa artikala u prodajnom nalogu. Također označava da odabrani artikal treba koristiti za paket artikala"
#. Option for the 'Plaid Environment' (Select) field in DocType 'Plaid
#. Settings'
@@ -62834,7 +62834,7 @@ msgstr "{0}, završi operaciju {1} prije operacije {2}."
#: erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py:61
msgid "{0}, {1} or {2} are the only allowed options."
msgstr ""
msgstr "{0}, {1} ili {2} su jedine dozvoljene opcije."
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:525
msgid "{0}: Child table (auto-deleted with parent)"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
"PO-Revision-Date: 2026-06-16 17:39\n"
"PO-Revision-Date: 2026-06-17 17:52\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -4507,7 +4507,7 @@ msgstr "آیتم جایگزین"
#: erpnext/stock/report/item_where_used/item_where_used.py:427
msgid "Alternative For Item"
msgstr ""
msgstr "جایگزین برای آیتم"
#. Label of the alternative_item_code (Link) field in DocType 'Item
#. Alternative'
@@ -6828,7 +6828,7 @@ msgstr "ابزار مقایسه BOM"
#: erpnext/stock/report/item_where_used/item_where_used.py:178
msgid "BOM Component"
msgstr ""
msgstr "مولفه BOM"
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
#: erpnext/manufacturing/doctype/bom/bom.json
@@ -6859,7 +6859,7 @@ msgstr "آیتم ایجاد کننده BOM"
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
msgid "BOM Creator Item with name {0} does not exist"
msgstr ""
msgstr "آیتم سازنده BOM با نام {0} وجود ندارد"
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
#. Supplied'
@@ -6959,7 +6959,7 @@ msgstr "زمان عملیات BOM"
#: erpnext/stock/report/item_where_used/item_where_used.py:248
msgid "BOM Output"
msgstr ""
msgstr "خروجی BOM"
#: erpnext/stock/report/item_prices/item_prices.py:60
msgid "BOM Rate"
@@ -7630,7 +7630,7 @@ msgstr "تراکنش بانکی {0} به روز شد"
#: banking/src/pages/BankReconciliation.tsx:118
msgid "Bank Transactions"
msgstr ""
msgstr "تراکنش‌های بانکی"
#: erpnext/setup/setup_wizard/operations/install_fixtures.py:584
msgid "Bank account cannot be named as {0}"
@@ -8132,13 +8132,13 @@ msgstr "تاریخ صورتحساب"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill Even If Previous Invoice Unpaid"
msgstr ""
msgstr "صدور صورتحساب حتی اگر فاکتور قبلی پرداخت نشده باشد"
#. Option for the 'Generate Invoice At' (Select) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill N days before period start"
msgstr ""
msgstr "صورتحساب N روز قبل از شروع دوره"
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
@@ -8320,13 +8320,13 @@ msgstr "ایمیل صورتحساب"
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Heatmap"
msgstr ""
msgstr "نقشه حرارتی صورتحساب"
#. Label of the billing_history_section (Section Break) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing History"
msgstr ""
msgstr "تاریخچه صورتحساب"
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
#. Timesheet'
@@ -8360,7 +8360,7 @@ msgstr "بازه صورتحساب در طرح اشتراک باید ماه با
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Period"
msgstr ""
msgstr "دوره صورتحساب"
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
@@ -8905,11 +8905,11 @@ msgstr "ساختمان ها"
#: banking/src/components/features/ActionLog/ActionLogDialogBody.tsx:88
msgid "Bulk Bank Entry"
msgstr ""
msgstr "ثبت بانک انبوه"
#: banking/src/components/features/ActionLog/ActionLogDialogBody.tsx:76
msgid "Bulk Payment"
msgstr ""
msgstr "پرداخت انبوه"
#: erpnext/utilities/doctype/rename_tool/rename_tool.js:71
msgid "Bulk Rename Jobs"
@@ -9457,7 +9457,7 @@ msgstr "لغو اشتراک پس از دوره مهلت"
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Cancel When Period Ends"
msgstr ""
msgstr "لغو هنگام پایان دوره"
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
@@ -13320,7 +13320,7 @@ msgstr "ایجاد سرنخ جدید"
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
msgid "Create New Version"
msgstr ""
msgstr "ایجاد نسخه جدید"
#: banking/src/components/common/LinkFieldCombobox.tsx:284
msgid "Create New {0}"
@@ -14201,12 +14201,12 @@ msgstr "نرخ ارز فعلی"
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice End"
msgstr ""
msgstr "پایان فاکتور فعلی"
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice Start"
msgstr ""
msgstr "شروع فاکتور فعلی"
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -17153,7 +17153,7 @@ msgstr ""
#: erpnext/stock/doctype/packed_item/packed_item.py:216
msgid "Disabled Product Bundle"
msgstr ""
msgstr "بسته محصول غیرفعال"
#: erpnext/stock/utils.py:434
msgid "Disabled Warehouse {0} cannot be used for this transaction."
@@ -25029,7 +25029,7 @@ msgstr "مرجع فروش داخلی وجود ندارد"
#. 'Supplier'
#: erpnext/buying/doctype/supplier/supplier.json
msgid "Internal Supplier Details"
msgstr ""
msgstr "جزئیات تأمین‌کننده داخلی"
#: erpnext/buying/doctype/supplier/supplier.py:180
msgid "Internal Supplier for company {0} already exists"
@@ -25060,7 +25060,7 @@ msgstr "مرجع انتقال داخلی وجود ندارد"
#. DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Internal Transfer Rules"
msgstr ""
msgstr "قوانین انتقال داخلی"
#: erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py:37
msgid "Internal Transfers"
@@ -25196,7 +25196,7 @@ msgstr "نوع سند نامعتبر است"
#: erpnext/selling/report/sales_analytics/sales_analytics.py:529
msgid "Invalid Document Type {0}"
msgstr ""
msgstr "نوع سند نامعتبر {0}"
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:207
msgid "Invalid File Type"
@@ -25313,7 +25313,7 @@ msgstr "انبار منبع و هدف نامعتبر"
#: erpnext/selling/report/sales_analytics/sales_analytics.py:507
msgid "Invalid Tree Type {0}"
msgstr ""
msgstr "نوع درخت نامعتبر {0}"
#: erpnext/edi/doctype/code_list/code_list_import.py:37
msgid "Invalid Upload"
@@ -25374,11 +25374,11 @@ msgstr "پرسمان جستجوی نامعتبر"
#: erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py:99
msgid "Invalid value {0} for 'Based On'"
msgstr ""
msgstr "مقدار نامعتبر {0} برای 'Based On'"
#: erpnext/selling/report/inactive_customers/inactive_customers.py:20
msgid "Invalid value {0} for 'Doctype'"
msgstr ""
msgstr "مقدار نامعتبر {0} برای 'Doctype'"
#: erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py:109
#: erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py:119
@@ -25411,7 +25411,7 @@ msgstr "فهرست موجودی"
#. Default'
#: erpnext/stock/doctype/item_default/item_default.json
msgid "Inventory Account"
msgstr ""
msgstr "حساب موجودی"
#. Label of the inventory_account_currency (Link) field in DocType 'Item
#. Default'
@@ -26993,7 +26993,7 @@ msgstr "نام گروه آیتم"
#: erpnext/setup/doctype/item_group/item_group.js:119
msgid "Item Group Override"
msgstr ""
msgstr "بازتعریف گروه آیتم"
#: erpnext/setup/doctype/item_group/item_group.js:82
msgid "Item Group Tree"
@@ -27267,7 +27267,7 @@ msgstr "آیتم موجود نیست"
#. Default'
#: erpnext/stock/doctype/item_default/item_default.json
msgid "Item Override"
msgstr ""
msgstr "بازتعریف آیتم"
#. Label of a Link in the Buying Workspace
#. Label of a Link in the Selling Workspace
@@ -29370,7 +29370,7 @@ msgstr "نگهداری موجودی"
#. DocType 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Maintain same rate throughout internal Transaction"
msgstr ""
msgstr "حفظ نرخ یکسان در کل تراکنش‌های داخلی"
#. Label of the maintain_same_sales_rate (Check) field in DocType 'Selling
#. Settings'
@@ -30760,7 +30760,7 @@ msgstr "ادغام پیشرفت"
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Merge similar Account Heads"
msgstr ""
msgstr "ادغام سر فصل‌های حساب مشابه"
#: erpnext/public/js/utils.js:1090
msgid "Merge taxes from multiple documents"
@@ -31140,7 +31140,7 @@ msgstr ""
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:929
msgid "Missing Dependency"
msgstr ""
msgstr "وابستگی گمشده"
#: erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py:44
msgid "Missing Filters"
@@ -31627,7 +31627,7 @@ msgstr "مقدار منفی مجاز نیست"
#. Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Negative Stock"
msgstr ""
msgstr "موجودی منفی"
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:1608
#: erpnext/stock/serial_batch_bundle.py:1549
@@ -32295,7 +32295,7 @@ msgstr "هیچ تامین کننده ای برای Inter Company Transactions ی
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:976
msgid "No Tables Detected"
msgstr ""
msgstr "هیچ جدولی شناسایی نشد"
#: erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py:100
msgid "No Tax Withholding data found for the current posting date."
@@ -32341,7 +32341,7 @@ msgstr "هیچ BOM فعالی برای آیتم {0} یافت نشد. تحویل
#: erpnext/stock/doctype/item/item_prices.html:135
msgid "No active item prices found."
msgstr ""
msgstr "هیچ قیمت آیتم فعالی یافت نشد."
#: erpnext/stock/doctype/item_variant_settings/item_variant_settings.js:46
msgid "No additional fields available"
@@ -43778,7 +43778,7 @@ msgstr "پیوند شطرنجی را تازه کنید"
#. Option for the 'Status' (Select) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Refunded"
msgstr ""
msgstr "استرداد وجه شده"
#: erpnext/stock/reorder_item.py:390
msgid "Regards,"
@@ -43889,7 +43889,7 @@ msgstr "مربوط"
#: erpnext/stock/report/item_where_used/item_where_used.py:50
msgid "Related Item"
msgstr ""
msgstr "آیتم مرتبط"
#. Label of the relation (Data) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -48858,7 +48858,7 @@ msgstr "مبلغ فروش"
#. Default'
#: erpnext/stock/doctype/item_default/item_default.json
msgid "Selling Cost Center"
msgstr ""
msgstr "مرکز هزینه فروش"
#: erpnext/stock/report/item_price_stock/item_price_stock.py:48
msgid "Selling Price List"
@@ -49026,7 +49026,7 @@ msgstr "شماره های سریال / دسته ای"
#. Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Serial Item settings"
msgstr ""
msgstr "تنظیمات آیتم سریال"
#. Label of the serial_no (Text) field in DocType 'POS Invoice Item'
#. Label of the serial_no (Text) field in DocType 'Purchase Invoice Item'
@@ -50640,7 +50640,7 @@ msgstr "نمایش جزئیات پرداخت"
#. 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Show Payment Schedule in print"
msgstr ""
msgstr "نمایش زمان‌بندی پرداخت در چاپ"
#. Label of the show_remarks (Check) field in DocType 'Process Statement Of
#. Accounts'
@@ -50684,12 +50684,12 @@ msgstr ""
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Show balances in Chart of Accounts"
msgstr ""
msgstr "نمایش ترازها در نمودار حساب‌ها"
#. Label of the show_barcode_field (Check) field in DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Show barcode field in stock transactions"
msgstr ""
msgstr "نمایش فیلد بارکد در تراکنش‌های موجودی"
#: erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.js:88
msgid "Show in Bucket View"
@@ -50704,7 +50704,7 @@ msgstr "نمایش در وب سایت"
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Show inclusive tax in print"
msgstr ""
msgstr "نمایش مالیات فراگیر در چاپ"
#. Description of the 'Reverse Sign' (Check) field in DocType 'Financial Report
#. Row'
@@ -50738,7 +50738,7 @@ msgstr "نمایش ثبت‌های در انتظار"
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Show taxes as table in print"
msgstr ""
msgstr "نمایش مالیات‌ها به صورت جدول در چاپ"
#: erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js:80
#: erpnext/accounts/report/trial_balance/trial_balance.js:100
@@ -50839,7 +50839,7 @@ msgstr ""
#: erpnext/stock/doctype/stock_entry/stock_entry.py:503
msgid "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
msgstr ""
msgstr "از آنجایی که برای کالای نهایی {1}، اتلاف فرآیند {0} واحد وجود دارد، شما باید مقدار {0} واحد برای کالای نهایی {1} در جدول آیتم‌ها را کاهش دهید."
#: erpnext/manufacturing/doctype/bom/bom.py:355
msgid "Since you have enabled 'Track Semi Finished Goods', at least one operation must have 'Is Final Finished Good' checked. For that set the FG / Semi FG Item as {0} against an operation."
@@ -51444,7 +51444,7 @@ msgstr ""
#. Label of the statement_password (Password) field in DocType 'Bank Account'
#: erpnext/accounts/doctype/bank_account/bank_account.json
msgid "Statement PDF Password"
msgstr ""
msgstr "گذرواژه PDF صورتحساب"
#: erpnext/accounts/report/general_ledger/general_ledger.html:145
msgid "Statement Period"
@@ -52288,7 +52288,7 @@ msgstr ""
#. Label of the stock_frozen_upto (Date) field in DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Stock frozen up to"
msgstr ""
msgstr "موجودی منجمد تا"
#: erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py:1140
msgid "Stock has been unreserved for work order {0}."
@@ -52556,7 +52556,7 @@ msgstr ""
#: erpnext/stock/report/item_where_used/item_where_used.py:362
msgid "Subcontracting Finished Good"
msgstr ""
msgstr "کالای نهایی پیمان‌کاری فرعی"
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
#. Settings'
@@ -52740,7 +52740,7 @@ msgstr ""
#: erpnext/stock/report/item_where_used/item_where_used.py:336
msgid "Subcontracting Service Item"
msgstr ""
msgstr "آیتم خدمات پیمانکاری فرعی"
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -52776,7 +52776,7 @@ msgstr "فاکتورهای تولید شده را ارسال کنید"
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Submit Journal entries"
msgstr ""
msgstr "ارسال ثبت‌های دفتر روزنامه"
#: erpnext/manufacturing/doctype/work_order/work_order.js:185
msgid "Submit this Work Order for further processing."
@@ -52788,7 +52788,7 @@ msgstr "پیش‌فاکتور خود را ارسال کنید"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
msgid "Submitted Job Card cannot be processed."
msgstr ""
msgstr "کارت شغلی ارسال‌شده قابل پردازش نیست."
#. Label of the subscription_section (Section Break) field in DocType 'Payment
#. Request'
@@ -53733,7 +53733,7 @@ msgstr "جدول برای آیتم که در وب سایت نشان داده خ
#: banking/src/components/features/BankStatementImporter/PDF/PDFTableEditor.tsx:312
#: banking/src/components/features/BankStatementImporter/PDF/PDFTableEditor.tsx:329
msgid "Table {0}"
msgstr ""
msgstr "جدول {0}"
#. Name of a UOM
#: erpnext/setup/setup_wizard/data/uom_data.json
@@ -54133,7 +54133,7 @@ msgstr "شناسه مالیاتی: {0}"
#. Label of the taxation_section (Section Break) field in DocType 'Supplier'
#: erpnext/buying/doctype/supplier/supplier.json
msgid "Tax Identification"
msgstr ""
msgstr "شناسایی مالیات"
#. Label of a Card Break in the Invoicing Workspace
#: erpnext/accounts/workspace/invoicing/invoicing.json
@@ -55545,7 +55545,7 @@ msgstr "هنگام انجام اقدام خطایی رخ داد."
#: banking/src/components/ui/error-banner.tsx:21
msgid "There was an error."
msgstr ""
msgstr "خطایی رخ داده است."
#: erpnext/accounts/doctype/bank/bank.js:112
#: erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js:119

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
"PO-Revision-Date: 2026-06-14 17:01\n"
"PO-Revision-Date: 2026-06-18 18:25\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"
@@ -1102,7 +1102,7 @@ msgstr "Klijent mora imati primarni kontakt e-poštu."
#. Description of the 'Disabled' (Check) field in DocType 'Product Bundle'
#: erpnext/selling/doctype/product_bundle/product_bundle.json
msgid "A disabled Product Bundle cannot be selected in transactions."
msgstr ""
msgstr "Onemogućeni Paket Artikal ne može se odabrati u transakcijama."
#: erpnext/stock/doctype/delivery_trip/delivery_trip.py:59
msgid "A driver must be set to submit."
@@ -4487,7 +4487,7 @@ msgstr "Dopusti Uređivanje Količine Jedinice Zaliha za Dokumente Prodaje"
#. DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Allow to edit stock UOM qty for Stock Entry"
msgstr ""
msgstr "Omogući uređivanje količine jedinice zaliha za Unos Zaliha"
#. Label of the allow_to_make_quality_inspection_after_purchase_or_delivery
#. (Check) field in DocType 'Stock Settings'
@@ -4597,7 +4597,7 @@ msgstr "Alternativni Artikal"
#: erpnext/stock/report/item_where_used/item_where_used.py:427
msgid "Alternative For Item"
msgstr ""
msgstr "Artikal Alternativa"
#. Label of the alternative_item_code (Link) field in DocType 'Item
#. Alternative'
@@ -6918,7 +6918,7 @@ msgstr "Alat Poređenja Sastavnica"
#: erpnext/stock/report/item_where_used/item_where_used.py:178
msgid "BOM Component"
msgstr ""
msgstr "Komponenta Sastavnice"
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
#: erpnext/manufacturing/doctype/bom/bom.json
@@ -6949,7 +6949,7 @@ msgstr "Artikal Sastavnice Konstruktora"
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
msgid "BOM Creator Item with name {0} does not exist"
msgstr ""
msgstr "Artikal Sastavnice s nazivom {0} ne postoji"
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
#. Supplied'
@@ -7049,7 +7049,7 @@ msgstr "Operativno Vrijeme Sastavnice"
#: erpnext/stock/report/item_where_used/item_where_used.py:248
msgid "BOM Output"
msgstr ""
msgstr "Sastavnica"
#: erpnext/stock/report/item_prices/item_prices.py:60
msgid "BOM Rate"
@@ -8222,13 +8222,13 @@ msgstr "Datum Fakture"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill Even If Previous Invoice Unpaid"
msgstr ""
msgstr "Fakturiraj čak i ako prethodna faktura nije plaćena"
#. Option for the 'Generate Invoice At' (Select) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Bill N days before period start"
msgstr ""
msgstr "Fakturiraj N dana prije početka perioda"
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
@@ -8410,13 +8410,13 @@ msgstr "e-pošta Fakture"
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Heatmap"
msgstr ""
msgstr "Toplinska mapa Fakturisanja"
#. Label of the billing_history_section (Section Break) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing History"
msgstr ""
msgstr "Povijest Fakturiranja"
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
#. Timesheet'
@@ -8450,7 +8450,7 @@ msgstr "Faktura Interval u Planu pretplate mora biti Mjesec koji prati kalendars
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Billing Period"
msgstr ""
msgstr "Razdoblje Fakturiranja"
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
@@ -9547,7 +9547,7 @@ msgstr "Otkaži Pretplatu nakon perioda odgode"
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Cancel When Period Ends"
msgstr ""
msgstr "Otkaži po završetku razdoblja"
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
@@ -9556,7 +9556,7 @@ msgstr "Datum Otkazivanja"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1567
msgid "Cancelled Job Card cannot be processed."
msgstr ""
msgstr "Otkazani Radni Nalog ne može se obraditi."
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:76
msgid "Cannot Assign Cashier"
@@ -9691,7 +9691,7 @@ msgstr "Nije moguće pretvoriti u Grupu jer je odabran Tip Računa."
#: erpnext/accounts/doctype/sales_invoice/mapper.py:372
msgid "Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. Please check the existing linked {2}s."
msgstr ""
msgstr "Nije moguće stvoriti međutvrtku {0}. Svi artikli u izvoru {1} već su u potpunosti fakturirani. Provjeri postojeće povezane {2}."
#: erpnext/stock/doctype/purchase_receipt/services/reservation.py:49
msgid "Cannot create Stock Reservation Entries for future dated Purchase Receipts."
@@ -9770,7 +9770,7 @@ msgstr "Nije moguće omogućiti račun zaliha po stavkama jer postoje postojeći
#: erpnext/crm/doctype/crm_settings/crm_settings.py:37
msgid "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
msgstr ""
msgstr "Nije moguće omogućiti stvaranje prilike iz Kontaktirajte Nas jer je obrazac Kontaktirajte Nas onemogućen."
#: erpnext/selling/doctype/sales_order/sales_order.py:624
#: erpnext/selling/doctype/sales_order/sales_order.py:647
@@ -9878,7 +9878,7 @@ msgstr "Brisanje nije moguće. Drugo brisanje {0} je već u redu čekanja/pokre
#: erpnext/manufacturing/doctype/job_card/job_card.py:922
msgid "Cannot submit Job Card {0} while it is On Hold. Please resume and complete the job before submission."
msgstr ""
msgstr "Nije moguće podnijeti Radni Nalog {0} dok je na čekanju. Nastavi i završi posao prije podnošenja."
#: erpnext/accounts/services/child_item_update.py:283
msgid "Cannot update rate as item {0} is already ordered or purchased against this quotation"
@@ -13410,7 +13410,7 @@ msgstr "Kreiraj novi trag"
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
msgid "Create New Version"
msgstr ""
msgstr "Stvori novu verziju"
#: banking/src/components/common/LinkFieldCombobox.tsx:284
msgid "Create New {0}"
@@ -14291,12 +14291,12 @@ msgstr "Trenutni Valuta kurs"
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice End"
msgstr ""
msgstr "Trenutni Završni Datum Fakture"
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Current Invoice Start"
msgstr ""
msgstr "Trenutni Početni Datum Fakture"
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -17243,7 +17243,7 @@ msgstr "Onemogućeni Bankovni Račun"
#: erpnext/stock/doctype/packed_item/packed_item.py:216
msgid "Disabled Product Bundle"
msgstr ""
msgstr "Onemogući Paket Artikala"
#: erpnext/stock/utils.py:434
msgid "Disabled Warehouse {0} cannot be used for this transaction."
@@ -18939,7 +18939,7 @@ msgstr "Omogući Program Bodova Lojalnosti"
#. DocType 'CRM Settings'
#: erpnext/crm/doctype/crm_settings/crm_settings.json
msgid "Enable Opportunity Creation from Contact Us"
msgstr ""
msgstr "Omogući stvaranje Prilika iz Kontaktiraj Nas obrasca"
#. Label of the enable_parallel_reposting (Check) field in DocType 'Stock
#. Reposting Settings'
@@ -20225,7 +20225,7 @@ msgstr "Nije uspjelo ažuriranje prioriteta pravila"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:521
msgid "Failed to update subscription status for {0} {1}"
msgstr ""
msgstr "Nije uspjelo ažuriranje statusa pretplate za {0} {1}"
#. Label of the failure_date (Datetime) field in DocType 'Asset Repair'
#: erpnext/assets/doctype/asset_repair/asset_repair.json
@@ -20765,7 +20765,7 @@ msgstr "Gotov Proizvod {0} ne odgovara Radnom Nalogu {1}"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:71
msgid "Finished good quantity being consumed ({0} in stock UOM) must equal the quantity to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the finished good row."
msgstr ""
msgstr "Količina gotovog proizvoda koja se troši ({0} u jedinici zaliha) mora biti jednaka količini za rastavljanje ({1}). Ne mijenjaj jedinicu, faktor konverzije ili količinu u redu gotovog proizvoda."
#: erpnext/selling/doctype/sales_order/sales_order.js:615
msgid "First Delivery Date"
@@ -23435,13 +23435,13 @@ msgstr "Ako je odbrano, ovaj artikal se tretira kao direktna dostava u Prodajnim
#. Description of the 'Update Stock' (Check) field in DocType 'Sales Invoice'
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.json
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately."
msgstr ""
msgstr "Ako je oodabrano, ažurira inventar; zalihe i knjigovodstveni unosi se kreiraju zajedno. Ostavi neodabrano ako se Dostavnica kreira zasebno."
#. Description of the 'Update Stock' (Check) field in DocType 'Purchase
#. Invoice'
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately."
msgstr ""
msgstr "Ako je odabrano, ažurira se inventar; unosi zaliha i knjigoovodstva se kreiraju zajedno. Ostavi neodabrano ako Kupovni Račun kreira zasebno."
#: erpnext/public/js/setup_wizard.js:56
msgid "If checked, we will create demo data for you to explore the system. This demo data can be erased later."
@@ -25255,7 +25255,7 @@ msgstr "Nevažeća Tvrtka za transakcije između tvrtki."
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:972
msgid "Invalid Configuration"
msgstr ""
msgstr "Nevažeća Konfiguracija"
#: erpnext/accounts/services/taxes.py:294
#: erpnext/assets/doctype/asset/asset.py:361
@@ -25273,12 +25273,12 @@ msgstr "Nevažeći Datum Dostave"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:110
msgid "Invalid Disassembly Item"
msgstr ""
msgstr "Nevažeći Artikala za Rastavljanje"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:76
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:125
msgid "Invalid Disassembly Quantity"
msgstr ""
msgstr "Nevažeća Količina za Rastavljanje"
#: erpnext/selling/page/point_of_sale/pos_item_cart.js:414
msgid "Invalid Discount"
@@ -26144,7 +26144,7 @@ msgstr "Je Fantomska Stavka"
#: erpnext/selling/doctype/sales_order_item/sales_order_item.json
#: erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
msgid "Is Product Bundle"
msgstr ""
msgstr "Je Paket Artikala"
#. Label of the po_required (Select) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -27652,7 +27652,7 @@ msgstr "Detalji Težine Artikla"
#. Name of a report
#: erpnext/stock/report/item_where_used/item_where_used.json
msgid "Item Where Used"
msgstr ""
msgstr "Gdje se koristi Artikal"
#. Label of a Link in the Buying Workspace
#. Name of a report
@@ -27775,7 +27775,7 @@ msgstr "Artikal {0} dodan je više puta pod isti nadređeni artikal {1} u redovi
#: erpnext/selling/doctype/product_bundle/product_bundle.js:54
msgid "Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}."
msgstr ""
msgstr "{0} već ima aktivan Paket Artikala ({1}). Podnošenjem ovog stvorit će se nova verzija i deaktivirati {1}."
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:119
msgid "Item {0} cannot be added as a sub-assembly of itself"
@@ -28106,7 +28106,7 @@ msgstr "Artikal Radne Kartice"
#: erpnext/manufacturing/doctype/job_card/job_card.py:925
msgid "Job Card On Hold"
msgstr ""
msgstr "Radni Nalog je na čekanju"
#. Name of a DocType
#: erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json
@@ -30309,7 +30309,7 @@ msgstr "Usklađeno"
#: erpnext/stock/report/item_where_used/item_where_used.py:57
msgid "Matched Field"
msgstr ""
msgstr "Usklađeno polje"
#. Label of the matched_transaction_rule (Link) field in DocType 'Bank
#. Transaction'
@@ -30928,7 +30928,7 @@ msgstr "Metar/Sekunda"
#: erpnext/manufacturing/doctype/workstation/workstation.py:546
msgid "Method {0} is not allowed to be run on a Job Card."
msgstr ""
msgstr "Metodu {0} nije dopušteno pokretati na Radnom Nalogu."
#. Name of a UOM
#: erpnext/setup/setup_wizard/data/uom_data.json
@@ -32258,13 +32258,13 @@ msgstr "Newton"
#. Label of the next_billing_period_end (Date) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Next Billing Period End"
msgstr ""
msgstr "Sljedeći Perioda Fakturiranja Završava"
#. Label of the next_billing_period_start (Date) field in DocType
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Next Billing Period Start"
msgstr ""
msgstr "Sljedeći Perioda Fakturiranja Počinje"
#. Label of the next_depreciation_date (Date) field in DocType 'Asset'
#: erpnext/assets/doctype/asset/asset.json
@@ -33476,7 +33476,7 @@ msgstr "Samo jedna operacija može imati odabranu opciju 'Je li Gotov Proizvod'
#. Description of the 'Is Active' (Check) field in DocType 'Product Bundle'
#: erpnext/selling/doctype/product_bundle/product_bundle.json
msgid "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one."
msgstr ""
msgstr "Samo jedna verzija Paketa Artikala može biti aktivna u datom trenutku za dati Nadređeni Artikal. Aktiviranje verzije deaktivira prethodno aktivnu verziju."
#: erpnext/stock/doctype/stock_entry/stock_entry.py:719
msgid "Only one {0} entry can be created against the Work Order {1}"
@@ -39083,7 +39083,7 @@ msgstr "Vremenska oznaka knjiženja mora biti nakon {0}"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Postpaid (bill at period end)"
msgstr ""
msgstr "Naknadno Plaćanje (faktura na kraju razdoblja)"
#. Description of a DocType
#: erpnext/crm/doctype/opportunity/opportunity.json
@@ -39186,7 +39186,7 @@ msgstr "Preferirana e-pošta"
#. 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Prepaid (bill at period start)"
msgstr ""
msgstr "Unaprijed Plaćeno (faktura na početku razdoblja)"
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:34
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:51
@@ -40174,7 +40174,7 @@ msgstr "Stanje Paketa Proizvoda"
#: erpnext/stock/report/item_where_used/item_where_used.py:278
msgid "Product Bundle Component"
msgstr ""
msgstr "Komponenta Paketa Artikala"
#. Label of the product_bundle_help (HTML) field in DocType 'POS Invoice'
#. Label of the product_bundle_help (HTML) field in DocType 'Sales Invoice'
@@ -40199,7 +40199,7 @@ msgstr "Artikal Paketa Proizvoda"
#: erpnext/stock/report/item_where_used/item_where_used.py:305
msgid "Product Bundle Parent"
msgstr ""
msgstr "Nadređeni Paket Artikala"
#. Description of the 'Product Bundle' (Link) field in DocType 'Purchase
#. Invoice Item'
@@ -40213,15 +40213,15 @@ msgstr ""
#: erpnext/stock/doctype/packed_item/packed_item.json
#: erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
msgid "Product Bundle version this row was packed from"
msgstr ""
msgstr "Verzija Paketa Artikala iz koje je ovaj red preuzet iz"
#: erpnext/stock/doctype/packed_item/packed_item.py:453
msgid "Product Bundle {0} is disabled and cannot be used in transactions."
msgstr ""
msgstr "Paket Artikala {0} je onemogućen i ne može se koristiti u transakcijama."
#: erpnext/stock/doctype/packed_item/packed_item.py:450
msgid "Product Bundle {0} is not submitted"
msgstr ""
msgstr "Paket Artikala {0} nije podnešen"
#. Label of the product_discount_scheme_section (Section Break) field in
#. DocType 'Pricing Rule'
@@ -43881,7 +43881,7 @@ msgstr "Osvježite Plaid Link"
#. Option for the 'Status' (Select) field in DocType 'Subscription'
#: erpnext/accounts/doctype/subscription/subscription.json
msgid "Refunded"
msgstr ""
msgstr "Povraćeno"
#: erpnext/stock/reorder_item.py:390
msgid "Regards,"
@@ -43992,7 +43992,7 @@ msgstr "Povezano"
#: erpnext/stock/report/item_where_used/item_where_used.py:50
msgid "Related Item"
msgstr ""
msgstr "Povezani Artikal"
#. Label of the relation (Data) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -46064,7 +46064,7 @@ msgstr "Red #{0}: Gotov Proizvod artikla nije navedena zaservisni artikal {1}"
#: erpnext/manufacturing/doctype/bom/bom.py:371
msgid "Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
msgstr ""
msgstr "Red #{0}: Artikal Gotovog Proizvoda {1} ne može se dodati u tablicu Sekundarnih Artikala."
#: erpnext/buying/doctype/purchase_order/services/subcontracting.py:28
#: erpnext/selling/doctype/sales_order/services/subcontracting.py:27
@@ -46155,7 +46155,7 @@ msgstr "Red #{0}: Artikal {1} nije artikal na zalihama"
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:106
msgid "Row #{0}: Item {1} is not part of the source manufacture entry and cannot be added to this disassembly."
msgstr ""
msgstr "Red #{0}: Artikal {1} nije dio unosa izvornog proizvođača i ne može se dodati ovom rastavljanju."
#: erpnext/controllers/subcontracting_inward_controller.py:79
msgid "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead."
@@ -46167,7 +46167,7 @@ msgstr "Red #{0}: Artikla {1} se ne slaže. Promjena koda artikla nije dozvoljen
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:115
msgid "Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity derived from the source ({3}). Do not change the UOM, conversion factor or quantity of disassembly rows."
msgstr ""
msgstr "Red #{0}: Količina artikla {1} ({2} u jedinici zaliha) ne odgovara količini izvedeno iz izvora ({3}). Ne mijenjaj jedinicu, faktor konverzije ili količinu redova za rastavljanje."
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:780
msgid "Row #{0}: Journal Entry {1} does not have account {2} or already matched against another voucher"
@@ -46233,7 +46233,7 @@ msgstr "Red #{0}: Postotnii Gubitka Procesa treba da bude manji od 100% za {1} a
#: erpnext/stock/doctype/packed_item/packed_item.py:213
msgid "Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions."
msgstr ""
msgstr "Red #{0}: Paket Artikal {1} je onemogućen i ne može se koristiti u transakcijama."
#: erpnext/public/js/utils/barcode_scanner.js:425
msgid "Row #{0}: Qty increased by {1}"
@@ -49490,7 +49490,7 @@ msgstr "Serijski i Šaržni Paket {0} nije podnešen"
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:2251
msgid "Serial and Batch Bundle {0} is submitted and its entries cannot be modified."
msgstr ""
msgstr "Serijski i Šaržni Paket {0} je podnešen i njegovi unosi se ne mogu mijenjati."
#. Label of the section_break_45 (Section Break) field in DocType
#. 'Subcontracting Receipt Item'
@@ -52668,7 +52668,7 @@ msgstr "Podizvođačka Dostava"
#: erpnext/stock/report/item_where_used/item_where_used.py:362
msgid "Subcontracting Finished Good"
msgstr ""
msgstr "Podizvođački Gotov Proizvod"
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
#. Settings'
@@ -52852,7 +52852,7 @@ msgstr "Podizvođački Prodajni Nalog"
#: erpnext/stock/report/item_where_used/item_where_used.py:336
msgid "Subcontracting Service Item"
msgstr ""
msgstr "Podizvođački Uslužni Artikal"
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -52900,7 +52900,7 @@ msgstr "Podnesi Ponudu"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
msgid "Submitted Job Card cannot be processed."
msgstr ""
msgstr "Podnešeni Radni Nalog ne može biti obrađen."
#. Label of the subscription_section (Section Break) field in DocType 'Payment
#. Request'
@@ -61720,7 +61720,7 @@ msgstr "Ne možete podnijeti nalog bez plaćanja."
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:968
msgid "You cannot update stock for a Debit Note. A Debit Note is a financial document that should not affect inventory. Please disable 'Update Stock'."
msgstr ""
msgstr "Ne možete ažurirati zalihe za Terećenje. Terećenje je financijski dokument koji ne bi trebao utjecati na zalihe. Onemogući opciju 'Ažuriraj Zalihe'."
#: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py:106
msgid "You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
@@ -62834,7 +62834,7 @@ msgstr "{0}, završi operaciju {1} prije operacije {2}."
#: erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py:61
msgid "{0}, {1} or {2} are the only allowed options."
msgstr ""
msgstr "{0}, {1} ili {2} su jedine dopuštene opcije."
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:525
msgid "{0}: Child table (auto-deleted with parent)"

View File

@@ -116,9 +116,12 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
# only item_code is grouped; the rest are functionally dependent on the grouped item (item
# attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres
# while returning the same value MySQL picked.
# only item_code is grouped; the remaining item-attribute columns are functionally dependent on it,
# so Max() returns their single value on both engines. is_phantom_item is the exception: the same
# item_code can sit on a phantom line and a real-RM line in one BOM, and get_subitems() drops any
# row whose is_phantom_item is truthy. Max() would let a single phantom line mask the real material
# and silently drop it; Min() instead treats the item as phantom only when EVERY line is phantom, so
# a real raw material is never lost. Deterministic and identical on MariaDB and Postgres.
return [
bom_item.item_code,
Max(item.default_material_request_type).as_("default_material_request_type"),
@@ -136,7 +139,7 @@ def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, pl
Max(item_uom.conversion_factor).as_("conversion_factor"),
Max(bom.item).as_("main_bom_item"),
Max(bom.name).as_("main_bom"),
Max(bom_item.is_phantom_item).as_("is_phantom_item"),
Min(bom_item.is_phantom_item).as_("is_phantom_item"),
]

View File

@@ -179,22 +179,25 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
.on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
.select(*_sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty))
.where(_sub_assembly_rm_filter(bei, bom, item, bom_no, include_non_stock_items))
.groupby(bei.item_code, bei.stock_uom)
.groupby(bei.item_code, bei.stock_uom, bei.bom_no, bei.is_phantom_item)
).run(as_dict=True)
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
# only item_code/stock_uom are grouped; every other column is functionally dependent on the
# grouped item (item attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY
# valid on postgres while returning the same value MySQL picked.
# Grouped by item_code/stock_uom plus bom_no/is_phantom_item: those two MUST come from the same
# BOM Item row -- the consumer keys on (item_code, bom_no) and recurses on is_phantom_item, so an
# independent Max() per column could pair a bom_no from one line with is_phantom_item from another
# and recurse into the wrong sub-BOM. Grouping them keeps the pair coherent and the GROUP BY valid
# on postgres. The remaining columns are functionally dependent on the grouped item; Max() returns
# their single value on both engines.
return [
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
Max(item.item_name).as_("item_name"),
Max(item.name).as_("item_code"),
Max(bei.description).as_("description"),
bei.stock_uom,
Max(bei.is_phantom_item).as_("is_phantom_item"),
Max(bei.bom_no).as_("bom_no"),
bei.is_phantom_item,
bei.bom_no,
Max(item.min_order_qty).as_("min_order_qty"),
Max(bei.source_warehouse).as_("source_warehouse"),
Max(item.default_material_request_type).as_("default_material_request_type"),

View File

@@ -2862,6 +2862,104 @@ class TestProductionPlan(ERPNextTestSuite):
"The phantom BOM was not re-exploded for the second po_item.",
)
def test_sub_assembly_rm_query_keeps_bom_no_phantom_pair_coherent(self):
"""bom_no and is_phantom_item must stay paired to the same BOM Item line.
When a component is listed more than once in a sub-assembly BOM pointing at different
sub-BOMs (one phantom, one not), grouping only by (item_code, stock_uom) collapsed both
lines into one row, and the independent Max() per column could pair the phantom flag of
one line with the bom_no of the other. The consumer keys on (item_code, bom_no) and
recurses on is_phantom_item, so an incoherent pair recurses into the wrong sub-BOM.
Grouping also by (bom_no, is_phantom_item) yields one coherent row per distinct sub-BOM.
"""
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_queries import (
_sub_assembly_rm_query,
)
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
rm_normal = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
# Phantom sub-BOM first (smaller auto-name); non-phantom second (larger name) -> the name
# the old Max(bom_no) would pick, while Max(is_phantom_item)=1 came from the phantom line.
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
normal_bom = make_bom(item=component, raw_materials=[rm_normal])
# Sub-assembly BOM lists `component` twice, once via each sub-BOM.
sa_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
sa_bom = make_bom(item=sa_item, raw_materials=[component], do_not_save=True)
sa_bom.items[0].bom_no = phantom_bom.name
component_doc = frappe.get_doc("Item", component)
sa_bom.append(
"items",
{
"item_code": component,
"qty": 1,
"uom": component_doc.stock_uom,
"stock_uom": component_doc.stock_uom,
"bom_no": normal_bom.name,
},
)
sa_bom.save()
sa_bom.submit()
rows = _sub_assembly_rm_query(
company="_Test Company", bom_no=sa_bom.name, include_non_stock_items=1, planned_qty=1
)
by_bom_no = {row.bom_no: row for row in rows if row.item_code == component}
# One coherent row per distinct sub-BOM, each carrying its own phantom flag.
self.assertIn(phantom_bom.name, by_bom_no)
self.assertIn(normal_bom.name, by_bom_no)
self.assertEqual(by_bom_no[phantom_bom.name].is_phantom_item, 1)
self.assertEqual(by_bom_no[normal_bom.name].is_phantom_item, 0)
def test_subitems_query_keeps_real_rm_listed_alongside_phantom(self):
"""bom_explosion._subitems_query groups BOM lines by item_code, and get_subitems() drops any
grouped row whose is_phantom_item is truthy. When one item_code is listed in a BOM both as a
phantom sub-assembly and as a plain raw material, Max(is_phantom_item)=1 made get_subitems
silently drop the real material. Min(is_phantom_item) keeps it (phantom only when every line
is phantom) and is deterministic on MariaDB and Postgres.
"""
from erpnext.manufacturing.doctype.production_plan.services.bom_explosion import _subitems_query
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
# the phantom BOM is auto-set as the component's default; clear it so the second component line
# stays a plain (non-phantom) raw material instead of inheriting the phantom BOM as its bom_no.
frappe.db.set_value("Item", component, "default_bom", "")
frappe.clear_document_cache("Item", component)
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
parent = make_bom(item=fg_item, raw_materials=[component], do_not_save=True)
parent.items[0].bom_no = phantom_bom.name # phantom line -> is_phantom_item = 1
component_doc = frappe.get_doc("Item", component)
parent.append(
"items",
{
"item_code": component,
"qty": 1,
"uom": component_doc.stock_uom,
"stock_uom": component_doc.stock_uom,
},
) # plain raw-material line (no bom_no) -> is_phantom_item = 0
parent.save()
parent.submit()
rows = _subitems_query("_Test Company", parent.name, 1, 1, 1)
component_rows = [r for r in rows if r.item_code == component]
self.assertEqual(len(component_rows), 1)
# Min() keeps the real material; the old Max() returned 1 and get_subitems dropped it.
self.assertEqual(component_rows[0].is_phantom_item, 0)
def create_production_plan(**args):
"""

View File

@@ -191,17 +191,25 @@ class RequiredItemsService:
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
# original_item is arbitrary per grouped item_code on MySQL -> Max() keeps the GROUP BY valid
# on postgres while returning the same value (it is only used as a dict key fallback below)
# original_item becomes the output dict key below, so it must stay coherent per row: the
# same item_code can be transferred both for itself (original_item NULL) and as a substitute
# for another required item (original_item set). Max() over a single item_code group could
# pick the substitute's original_item and misattribute the item's own transfer to it. Group
# by (item_code, original_item) so each pair sums separately, then accumulate into the keyed
# dict (two distinct rows can resolve to the same key, e.g. A's own transfer and B-for-A).
.select(
ste_child.item_code,
fn.Max(ste_child.original_item).as_("original_item"),
ste_child.original_item,
fn.Sum(ste_child.transfer_qty).as_("qty"),
)
.where(self._material_transfer_filter(ste, is_return))
.groupby(ste_child.item_code)
.groupby(ste_child.item_code, ste_child.original_item)
)
return frappe._dict({d.original_item or d.item_code: d.qty for d in (query.run(as_dict=1) or [])})
qty_by_item = frappe._dict()
for d in query.run(as_dict=1) or []:
key = d.original_item or d.item_code
qty_by_item[key] = (qty_by_item.get(key) or 0.0) + flt(d.qty)
return qty_by_item
def _material_transfer_filter(self, ste, is_return):
return (

View File

@@ -4815,6 +4815,57 @@ class TestWorkOrder(ERPNextTestSuite):
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
self.assertEqual(flt(row.qty, 6), 3.0)
def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self):
"""When one item is transferred both for itself and as a substitute for another required item,
each transfer must be credited to the right required item.
_material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked
Max(original_item); for item B transferred once for itself (original_item NULL) and once as a
substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving
B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes
each transfer correctly, deterministically on MariaDB and Postgres.
"""
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
source_warehouse = "Stores - _TC"
fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name
item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name
item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name
# B is a registered alternative for A
if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}):
frappe.get_doc(
{
"doctype": "Item Alternative",
"item_code": item_a,
"alternative_item_code": item_b,
"two_way": 1,
}
).insert()
# stock B generously (covers B-for-A plus B-for-itself)
for item, qty in ((item_a, 50), (item_b, 100)):
test_stock_entry.make_stock_entry(
item_code=item, target=source_warehouse, qty=qty, basic_rate=100
)
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b])
wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse)
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
transfer.save()
# substitute B for the A line; the existing B line stays as B's own transfer
for d in transfer.items:
if d.item_code == item_a:
d.item_code = item_b
d.original_item = item_a
transfer.submit()
qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0)
# B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B.
self.assertEqual(flt(qty_by_item.get(item_a)), 10.0)
self.assertEqual(flt(qty_by_item.get(item_b)), 10.0)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -233,13 +233,33 @@ def get_bom_data(filters):
else:
query = query.where(bin.warehouse == filters.get("warehouse"))
if bom_item_table == "BOM Item":
query = query.select(
Max(bom_item.bom_no).as_("bom_no"), Max(bom_item.is_phantom_item).as_("is_phantom_item")
)
data = query.run(as_dict=True)
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
if bom_item_table == "BOM Item":
# bom_no + is_phantom_item drive whether/which sub-BOM explode_phantom_boms recurses into, so
# they must come from the SAME BOM Item line. Aggregating each independently (Max) could pair a
# bom_no from one line with is_phantom_item from another when an item_code repeats in the BOM.
# Rows are grouped by item_code (one qty_per_unit total per component), so pick one coherent
# representative line: the first line, but upgrade to the first phantom line if any exists, so a
# phantom sub-BOM is never dropped just because a non-phantom line happens to be listed first.
representative = {}
for line in frappe.get_all(
"BOM Item",
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
fields=["item_code", "bom_no", "is_phantom_item"],
order_by="idx",
):
existing = representative.get(line.item_code)
if existing is None or (line.is_phantom_item and not existing.is_phantom_item):
representative[line.item_code] = line
for row in data:
line = representative.get(row.item_code)
if line:
row.bom_no = line.bom_no
row.is_phantom_item = line.is_phantom_item
return explode_phantom_boms(data, filters)
return data
def explode_phantom_boms(data, filters):

View File

@@ -79,6 +79,73 @@ class TestBOMStockAnalysis(ERPNextTestSuite):
)
self.assertEqual(footer.get("description"), expected_min)
def _build_duplicate_component_bom(self, phantom_first):
"""Parent BOM that lists one `component` twice, once via a phantom sub-BOM and once via a
non-phantom sub-BOM. `phantom_first` controls which line is at idx 1. Returns the names of
(parent_bom, rm_phantom, rm_normal, component)."""
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
rm_normal = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
# Phantom sub-BOM created first -> smaller auto-name; non-phantom second -> larger name,
# which is exactly what the old Max(bom_no) would (incorrectly) pick.
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
normal_bom = make_bom(item=component, raw_materials=[rm_normal])
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
first_bom, second_bom = (
(phantom_bom.name, normal_bom.name) if phantom_first else (normal_bom.name, phantom_bom.name)
)
parent = make_bom(item=fg_item, raw_materials=[component], do_not_save=True)
parent.items[0].bom_no = first_bom
component_doc = frappe.get_doc("Item", component)
parent.append(
"items",
{
"item_code": component,
"qty": 1,
"uom": component_doc.stock_uom,
"stock_uom": component_doc.stock_uom,
"bom_no": second_bom,
},
)
parent.save()
parent.submit()
return parent.name, rm_phantom, rm_normal, component
def _assert_phantom_exploded(self, parent_bom, rm_phantom, rm_normal, component):
raw_data = bom_stock_analysis_report(filters={"qty_to_make": 1, "bom": parent_bom})[1]
items = {row.get("item") for row in raw_data if row}
# Phantom sub-BOM exploded -> its raw material appears; the component row is replaced.
self.assertIn(rm_phantom, items)
self.assertNotIn(component, items)
# The non-phantom line's sub-BOM must NOT be mis-exploded.
self.assertNotIn(rm_normal, items)
def test_phantom_explosion_picks_coherent_sub_bom(self):
"""bom_no and is_phantom_item must come from the SAME BOM Item line.
When a component is listed more than once in a BOM pointing at different sub-BOMs
(one phantom, one not), the report groups both lines into a single row by item_code.
Aggregating bom_no and is_phantom_item with independent Max() could pair the phantom
flag of one line with the bom_no of the other, so explode_phantom_boms recurses into
the wrong sub-BOM. We now take one coherent representative line, so the phantom sub-BOM
is the one exploded.
"""
self._assert_phantom_exploded(*self._build_duplicate_component_bom(phantom_first=True))
def test_phantom_explosion_when_phantom_line_is_not_first(self):
"""The phantom flag must win regardless of line order.
If the non-phantom line is listed first (idx 1) and the phantom line second, a naive
first-line representative would drop the phantom flag and skip the sub-BOM explosion.
The representative is phantom-preferring, so the phantom sub-BOM is still exploded.
"""
self._assert_phantom_exploded(*self._build_duplicate_component_bom(phantom_first=False))
def split_data_and_footer(raw_data):
"""Separate component rows from the footer row. Skips blank spacer rows."""

View File

@@ -488,3 +488,4 @@ erpnext.patches.v16_0.rename_secondary_item_type_field
erpnext.patches.v16_0.submit_existing_product_bundles #1
erpnext.patches.v16_0.migrate_subscription_generate_invoice_at
erpnext.patches.v16_0.rename_subscription_billing_period_fields
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb

View File

@@ -0,0 +1,7 @@
from frappe.database.utils import drop_index_if_exists
def execute():
drop_index_if_exists("tabSerial and Batch Entry", "serial_no")
drop_index_if_exists("tabSerial and Batch Entry", "warehouse")
drop_index_if_exists("tabSerial and Batch Entry", "type_of_transaction")

View File

@@ -43,9 +43,13 @@ class ActivityCost(Document):
def check_unique(self):
if self.employee:
if frappe.db.sql(
"""select name from `tabActivity Cost` where employee_name= %s and activity_type= %s and name != %s""",
(self.employee_name, self.activity_type, self.name),
if frappe.db.exists(
"Activity Cost",
{
"employee_name": self.employee_name,
"activity_type": self.activity_type,
"name": ["!=", self.name],
},
):
frappe.throw(
_("Activity Cost exists for Employee {0} against Activity Type - {1}").format(
@@ -54,9 +58,13 @@ class ActivityCost(Document):
DuplicationError,
)
else:
if frappe.db.sql(
"""select name from `tabActivity Cost` where ifnull(employee, '')='' and activity_type= %s and name != %s""",
(self.activity_type, self.name),
if frappe.db.exists(
"Activity Cost",
{
"employee": ["is", "not set"],
"activity_type": self.activity_type,
"name": ["!=", self.name],
},
):
frappe.throw(
_("Default Activity Cost exists for Activity Type - {0}").format(self.activity_type),

View File

@@ -4,15 +4,14 @@
import frappe
from email_reply_parser import EmailReplyParser
from frappe import _, qb
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
from frappe.query_builder import Case, Interval
from frappe.query_builder.functions import Count, CurDate, Date, Locate, Lower, Sum, UnixTimestamp
from frappe.utils import add_days, flt, get_datetime, get_link_to_form, get_time, nowtime, today
from frappe.utils.user import is_website_user
from pypika import Order
from erpnext import get_default_company
from erpnext.controllers.queries import get_filters_cond
from erpnext.controllers.website_list_for_contact import get_customers_suppliers
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -74,16 +73,15 @@ class Project(Document):
# end: auto-generated types
def onload(self):
timesheet_detail = frappe.qb.DocType("Timesheet Detail")
self.set_onload(
"activity_summary",
frappe.db.sql(
"""select activity_type,
sum(hours) as total_hours
from `tabTimesheet Detail` where project=%s and docstatus < 2 group by activity_type
order by total_hours desc""",
self.name,
as_dict=True,
),
frappe.qb.from_(timesheet_detail)
.select(timesheet_detail.activity_type, Sum(timesheet_detail.hours).as_("total_hours"))
.where((timesheet_detail.project == self.name) & (timesheet_detail.docstatus < 2))
.groupby(timesheet_detail.activity_type)
.orderby("total_hours", order=frappe.qb.desc)
.run(as_dict=True),
)
def before_print(self, settings=None):
@@ -102,7 +100,7 @@ class Project(Document):
"""
Copy tasks from template
"""
if self.project_template and not frappe.db.get_all("Task", dict(project=self.name), limit=1):
if self.project_template and not frappe.db.exists("Task", {"project": self.name}):
# has a template, and no loaded tasks, so lets create
if not self.expected_start_date:
# project starts today
@@ -240,8 +238,29 @@ class Project(Document):
def after_insert(self):
self.copy_from_template("after_insert")
if self.sales_order:
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
self.link_with_sales_order()
def link_with_sales_order(self) -> None:
"""Back-link the source Sales Order to this project.
The link is set only when the Sales Order is not already tied to another
project, so projects created concurrently for the same Sales Order cannot
overwrite each other's reference.
"""
if not self.sales_order:
return
existing_project = frappe.db.get_value("Sales Order", self.sales_order, "project")
if existing_project and existing_project != self.name:
frappe.msgprint(
_("Sales Order {0} is already linked to Project {1}, skipping the link.").format(
self.sales_order, existing_project
),
alert=True,
)
return
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
def on_trash(self):
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
@@ -267,32 +286,25 @@ class Project(Document):
if (self.percent_complete_method == "Task Completion" and total > 0) or (
not self.percent_complete_method and total > 0
):
completed = frappe.db.sql(
"""select count(name) from tabTask where
project=%s and status in ('Cancelled', 'Completed')""",
self.name,
)[0][0]
completed = frappe.db.count(
"Task", {"project": self.name, "status": ["in", ["Cancelled", "Completed"]]}
)
self.percent_complete = flt(flt(completed) / total * 100, 2)
if self.percent_complete_method == "Task Progress" and total > 0:
progress = frappe.db.sql(
"""select sum(progress) from tabTask where
project=%s""",
self.name,
task = frappe.qb.DocType("Task")
progress = (
frappe.qb.from_(task).select(Sum(task.progress)).where(task.project == self.name).run()
)[0][0]
self.percent_complete = flt(flt(progress) / total, 2)
if self.percent_complete_method == "Task Weight" and total > 0:
weight_sum = frappe.db.sql(
"""select sum(task_weight) from tabTask where
project=%s""",
self.name,
task = frappe.qb.DocType("Task")
weight_sum = (
frappe.qb.from_(task).select(Sum(task.task_weight)).where(task.project == self.name).run()
)[0][0]
weighted_progress = frappe.db.sql(
"""select progress, task_weight from tabTask where
project=%s""",
self.name,
as_dict=1,
weighted_progress = frappe.get_all(
"Task", filters={"project": self.name}, fields=["progress", "task_weight"]
)
pct_complete = 0
for row in weighted_progress:
@@ -353,10 +365,12 @@ class Project(Document):
self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0
def update_sales_amount(self):
total_sales_amount = frappe.db.sql(
"""select sum(base_net_total)
from `tabSales Order` where project = %s and docstatus=1""",
self.name,
so = frappe.qb.DocType("Sales Order")
total_sales_amount = (
frappe.qb.from_(so)
.select(Sum(so.base_net_total))
.where((so.project == self.name) & (so.docstatus == 1))
.run()
)
self.total_sales_amount = total_sales_amount and total_sales_amount[0][0] or 0
@@ -365,25 +379,31 @@ class Project(Document):
self.total_billed_amount = self.get_billed_amount_from_parent() + self.get_billed_amount_from_child()
def get_billed_amount_from_parent(self):
total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Invoice` si join `tabSales Invoice Item` si_item on si_item.parent = si.name
where si_item.project is null
and si.project is not null
and si.project = %s
and si.docstatus = 1""",
self.name,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
total_billed_amount = (
frappe.qb.from_(si)
.join(si_item)
.on(si_item.parent == si.name)
.select(Sum(si_item.base_net_amount))
.where(
si_item.project.isnull()
& si.project.isnotnull()
& (si.project == self.name)
& (si.docstatus == 1)
)
.run()
)
return total_billed_amount and total_billed_amount[0][0] or 0
def get_billed_amount_from_child(self):
total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Invoice Item`
where project = %s
and docstatus = 1""",
self.name,
si_item = frappe.qb.DocType("Sales Invoice Item")
total_billed_amount = (
frappe.qb.from_(si_item)
.select(Sum(si_item.base_net_amount))
.where((si_item.project == self.name) & (si_item.docstatus == 1))
.run()
)
return total_billed_amount and total_billed_amount[0][0] or 0
@@ -499,28 +519,43 @@ def get_list_context(context=None):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_users_for_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
conditions = []
return frappe.db.sql(
"""select name, concat_ws(' ', first_name, middle_name, last_name)
from `tabUser`
where enabled=1
and name not in ("Guest", "Administrator")
and ({key} like %(txt)s
or full_name like %(txt)s)
{fcond} {mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
(case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end),
idx desc,
name, full_name
limit %(page_len)s offset %(start)s""".format(
**{
"key": searchfield,
"fcond": get_filters_cond(doctype, filters, conditions),
"mcond": get_match_cond(doctype),
}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
User = frappe.qb.DocType("User")
search_str = f"%{txt}%"
txt_no_percent = txt.replace("%", "")
query = frappe.qb.get_query(
"User",
fields=["name", "full_name"],
filters=filters,
ignore_permissions=False,
)
return (
query.where(User.enabled == 1)
.where(User.name.notin(["Guest", "Administrator"]))
.where(User[searchfield].like(search_str) | User.full_name.like(search_str))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(User.name)) > 0,
Locate(Lower(txt_no_percent), Lower(User.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(User.full_name)) > 0,
Locate(Lower(txt_no_percent), Lower(User.full_name)),
)
.else_(99999)
)
.orderby(User.idx, order=Order.desc)
.orderby(User.name)
.orderby(User.full_name)
.limit(page_len)
.offset(start)
.run()
)
@@ -580,11 +615,7 @@ def weekly_reminder():
def allow_to_make_project_update(project, time, frequency):
data = frappe.db.sql(
""" SELECT name from `tabProject Update`
WHERE project = %s and date = %s """,
(project, today()),
)
data = frappe.get_all("Project Update", filters={"project": project, "date": today()}, pluck="name")
# len(data) > 1 condition is checked for twicely frequency
if data and (frequency in ["Daily", "Weekly"] or len(data) > 1):

View File

@@ -174,6 +174,25 @@ class TestProject(ERPNextTestSuite):
so.reload()
self.assertFalse(so.project)
def test_sales_order_link_is_not_overwritten_by_second_project(self):
so = make_sales_order()
first_project = make_project_from_so(so.name).save()
so.reload()
self.assertEqual(so.project, first_project.name)
# A second project for the same sales order must not steal the link.
second_project = frappe.get_doc(
doctype="Project",
project_name="Second project for same sales order",
company=so.company,
sales_order=so.name,
).insert()
self.assertEqual(second_project.sales_order, so.name)
so.reload()
self.assertEqual(so.project, first_project.name)
def test_project_with_template_tasks_having_common_name(self):
# Step - 1: Create Template Parent Tasks
template_parent_task1 = create_task(subject="Parent Task - 1", is_template=1, is_group=1)

View File

@@ -76,10 +76,9 @@ class Task(NestedSet):
nsm_parent_field = "parent_task"
def get_customer_details(self):
cust = frappe.db.sql("select customer_name from `tabCustomer` where name=%s", self.customer)
if cust:
ret = {"customer_name": cust and cust[0][0] or ""}
return ret
customer_name = frappe.db.get_value("Customer", self.customer, "customer_name")
if customer_name:
return {"customer_name": customer_name or ""}
def validate(self):
self.validate_dates()
@@ -252,9 +251,11 @@ class Task(NestedSet):
for d in check_list:
task_list, count = [self.name], 0
while len(task_list) > count:
tasks = frappe.db.sql(
" select {} from `tabTask Depends On` where {} = {} ".format(d[0], d[1], "%s"),
cstr(task_list[count]),
tasks = frappe.get_all(
"Task Depends On",
filters={d[1]: cstr(task_list[count])},
fields=[d[0]],
as_list=True,
)
count = count + 1
for b in tasks:
@@ -268,30 +269,34 @@ class Task(NestedSet):
def reschedule_dependent_tasks(self):
end_date = self.exp_end_date or self.act_end_date
if end_date:
for task_name in frappe.db.sql(
"""
select name from `tabTask` as parent
where parent.project = %(project)s
and parent.name in (
select parent from `tabTask Depends On` as child
where child.task = %(task)s and child.project = %(project)s)
""",
{"project": self.project, "task": self.name},
as_dict=1,
if not end_date:
return
dependent_parents = frappe.get_all(
"Task Depends On",
filters={"task": self.name, "project": self.project},
pluck="parent",
)
if not dependent_parents:
return
for task_name in frappe.get_all(
"Task",
filters={"project": self.project, "name": ["in", dependent_parents]},
pluck="name",
):
task = frappe.get_doc("Task", task_name)
if (
task.exp_start_date
and task.exp_end_date
and task.exp_start_date < end_date
and task.status == "Open"
):
task = frappe.get_doc("Task", task_name.name)
if (
task.exp_start_date
and task.exp_end_date
and task.exp_start_date < end_date
and task.status == "Open"
):
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
task.exp_start_date = add_days(end_date, 1)
task.exp_end_date = add_days(task.exp_start_date, task_duration)
task.flags.ignore_recursion_check = True
task.save()
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
task.exp_start_date = add_days(end_date, 1)
task.exp_end_date = add_days(task.exp_start_date, task_duration)
task.flags.ignore_recursion_check = True
task.save()
def has_webform_permission(self):
project_user = frappe.db.get_value(
@@ -337,27 +342,23 @@ def check_if_child_exists(name: str):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
from erpnext.controllers.queries import get_match_cond
from frappe.query_builder import Criterion
meta = frappe.get_meta(doctype)
searchfields = meta.get_search_fields()
search_columns = ", " + ", ".join(searchfields) if searchfields else ""
search_cond = " or " + " or ".join(field + " like %(txt)s" for field in searchfields)
searchfields = frappe.get_meta(doctype).get_search_fields()
return frappe.db.sql(
f""" select name {search_columns} from `tabProject`
where %(key)s like %(txt)s
%(mcond)s
{search_cond}
order by name
limit %(page_len)s offset %(start)s""",
{
"key": searchfield,
"txt": "%" + txt + "%",
"mcond": get_match_cond(doctype),
"start": start,
"page_len": page_len,
},
Project = frappe.qb.DocType("Project")
search_str = f"%{txt}%"
search_fields = list(dict.fromkeys([searchfield, *searchfields]))
search_conditions = [Project[field].like(search_str) for field in search_fields]
query = frappe.qb.get_query("Project", fields=["name", *searchfields], ignore_permissions=False)
return (
query.where(Criterion.any(search_conditions))
.orderby(Project.name)
.limit(page_len)
.offset(start)
.run()
)

View File

@@ -126,6 +126,80 @@ class TestTimesheet(ERPNextTestSuite):
self.assertEqual(ts.per_billed, 100)
self.assertEqual(ts.time_logs[0].sales_invoice, sales_invoice.name)
def _bill_timesheet_into_invoice(self, emp):
"""Submit a billable timesheet into a Sales Invoice; return (timesheet, invoice)."""
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice.due_date = nowdate()
sales_invoice.submit()
timesheet.reload()
# Submitting links the timesheet detail to the invoice and marks it billed
self.assertEqual(timesheet.time_logs[0].sales_invoice, sales_invoice.name)
self.assertEqual(timesheet.status, "Billed")
return timesheet, sales_invoice
def test_timesheet_billing_link_lifecycle(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("link released on cancel"):
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
sales_invoice.reload()
sales_invoice.cancel()
timesheet.reload()
self.assertFalse(timesheet.time_logs[0].sales_invoice)
self.assertNotEqual(timesheet.status, "Billed")
with self.subTest("link released on sales return"):
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
sales_return = make_sales_return(sales_invoice.name)
sales_return.insert()
sales_return.submit()
timesheet.reload()
self.assertFalse(timesheet.time_logs[0].sales_invoice)
def test_timesheet_billing_validations(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("unsubmitted timesheet is rejected"):
draft = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
sales_invoice = self._invoice_with_timesheet_row(draft.name, draft.time_logs[0].name)
self.assertRaises(frappe.ValidationError, sales_invoice.save)
with self.subTest("already invoiced detail is rejected"):
timesheet, _ = self._bill_timesheet_into_invoice(emp)
sales_invoice = self._invoice_with_timesheet_row(timesheet.name, timesheet.time_logs[0].name)
self.assertRaises(frappe.ValidationError, sales_invoice.save)
@ERPNextTestSuite.change_settings("Projects Settings", {"fetch_timesheet_in_sales_invoice": 1})
def test_timesheet_billing_data_population(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("blank hours/amount are back-filled from the timesheet"):
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
sales_invoice = self._invoice_with_timesheet_row(
timesheet.name, timesheet.time_logs[0].name, with_amounts=False
)
sales_invoice.save()
self.assertEqual(sales_invoice.timesheets[0].billing_hours, 2)
self.assertEqual(sales_invoice.timesheets[0].billing_amount, 100)
with self.subTest("project invoice auto-fetches the project's timesheets"):
project = frappe.get_value("Project", {"project_name": "_Test Project"})
make_timesheet(emp, simulate=True, is_billable=1, project=project, company="_Test Company")
sales_invoice = create_sales_invoice(do_not_save=True)
sales_invoice.project = project
sales_invoice.set("timesheets", [])
sales_invoice.save()
self.assertTrue(sales_invoice.timesheets)
def _invoice_with_timesheet_row(self, time_sheet, timesheet_detail, with_amounts=True):
sales_invoice = create_sales_invoice(do_not_save=True)
row = {"time_sheet": time_sheet, "timesheet_detail": timesheet_detail}
if with_amounts:
row.update({"billing_hours": 2, "billing_amount": 100})
sales_invoice.append("timesheets", row)
return sales_invoice
def test_timesheet_time_overlap(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")

View File

@@ -7,10 +7,10 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Concat, Date, Round
from frappe.utils import flt, get_datetime, getdate
from frappe.utils.deprecations import deprecated
from erpnext.controllers.queries import get_match_cond
from erpnext.setup.utils import get_exchange_rate
@@ -308,41 +308,41 @@ def get_projectwise_timesheet_data(
from_time: str | None = None,
to_time: str | None = None,
):
condition = ""
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(ts.name == tsd.parent)
.select(
tsd.name.as_("name"),
tsd.parent.as_("time_sheet"),
tsd.from_time.as_("from_time"),
tsd.to_time.as_("to_time"),
tsd.billing_hours.as_("billing_hours"),
tsd.billing_amount.as_("billing_amount"),
tsd.activity_type.as_("activity_type"),
tsd.description.as_("description"),
ts.currency.as_("currency"),
tsd.project_name.as_("project_name"),
)
.where(
(tsd.parenttype == "Timesheet")
& (tsd.docstatus == 1)
& (tsd.is_billable == 1)
& tsd.sales_invoice.isnull()
)
)
if project:
condition += "AND tsd.project = %(project)s "
query = query.where(tsd.project == project)
if parent:
condition += "AND tsd.parent = %(parent)s "
query = query.where(tsd.parent == parent)
if from_time and to_time:
condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s"
query = query.where(Date(tsd.from_time).between(from_time, to_time))
query = f"""
SELECT
tsd.name as name,
tsd.parent as time_sheet,
tsd.from_time as from_time,
tsd.to_time as to_time,
tsd.billing_hours as billing_hours,
tsd.billing_amount as billing_amount,
tsd.activity_type as activity_type,
tsd.description as description,
ts.currency as currency,
tsd.project_name as project_name
FROM `tabTimesheet Detail` tsd
INNER JOIN `tabTimesheet` ts
ON ts.name = tsd.parent
WHERE
tsd.parenttype = 'Timesheet'
AND tsd.docstatus = 1
AND tsd.is_billable = 1
AND tsd.sales_invoice is NULL
{condition}
ORDER BY tsd.from_time ASC
"""
filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time}
return frappe.db.sql(query, filters, as_dict=1)
return query.orderby(tsd.from_time).run(as_dict=1)
@frappe.whitelist()
@@ -372,25 +372,28 @@ def get_timesheet(doctype: str, txt: str, searchfield: str, start: int, page_len
if not filters:
filters = {}
condition = ""
if filters.get("project"):
condition = "and tsd.project = %(project)s"
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
return frappe.db.sql(
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
`tabTimesheet` ts where
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
tsd.docstatus = 1 and ts.total_billable_amount > 0
and tsd.parent LIKE %(txt)s {condition}
order by tsd.parent limit %(page_len)s offset %(start)s""",
{
"txt": "%" + txt + "%",
"start": start,
"page_len": page_len,
"project": filters.get("project"),
},
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(tsd.parent == ts.name)
.select(tsd.parent)
.distinct()
.where(
ts.status.isin(["Submitted", "Payslip"])
& (tsd.docstatus == 1)
& (ts.total_billable_amount > 0)
& tsd.parent.like(f"%{txt}%")
)
)
if filters.get("project"):
query = query.where(tsd.project == filters.get("project"))
return query.orderby(tsd.parent).limit(page_len).offset(start).run()
@frappe.whitelist()
def get_timesheet_data(name: str, project: str):
@@ -500,27 +503,37 @@ def get_events(start: str, end: str, filters: str | None = None):
:param end: End date-time.
:param filters: Filters (JSON).
"""
from erpnext.utilities.query import get_event_conditions_qb
filters = json.loads(filters) if filters else {}
from frappe.desk.calendar import get_event_conditions
conditions = get_event_conditions("Timesheet", filters)
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
return frappe.db.sql(
"""select `tabTimesheet Detail`.name as name,
`tabTimesheet Detail`.docstatus as status, `tabTimesheet Detail`.parent as parent,
from_time as start_date, hours, activity_type,
`tabTimesheet Detail`.project, to_time as end_date,
CONCAT(`tabTimesheet Detail`.parent, ' (', ROUND(hours,2),' hrs)') as title
from `tabTimesheet Detail`, `tabTimesheet`
where `tabTimesheet Detail`.parent = `tabTimesheet`.name
and `tabTimesheet`.docstatus < 2
and (from_time <= %(end)s and to_time >= %(start)s) {conditions} {match_cond}
""".format(conditions=conditions, match_cond=get_match_cond("Timesheet")),
{"start": start, "end": end},
as_dict=True,
update={"allDay": 0},
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(tsd.parent == ts.name)
.select(
tsd.name.as_("name"),
tsd.docstatus.as_("status"),
tsd.parent.as_("parent"),
tsd.from_time.as_("start_date"),
tsd.hours,
tsd.activity_type,
tsd.project,
tsd.to_time.as_("end_date"),
Concat(tsd.parent, " (", Round(tsd.hours, 2), " hrs)").as_("title"),
)
.where((ts.docstatus < 2) & (tsd.from_time <= end) & (tsd.to_time >= start))
)
# user-permission match conditions + calendar filters on Timesheet (query-builder form)
for condition in get_event_conditions_qb("Timesheet", filters):
query = query.where(condition)
return query.run(as_dict=True, update={"allDay": 0})
def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="creation"):
user = frappe.session.user

View File

@@ -4,19 +4,16 @@
import frappe
from frappe import _
from frappe.desk.reportview import build_match_conditions
from frappe.utils import add_days, getdate
from erpnext.stock.utils import get_combine_datetime
def execute(filters=None):
if not filters:
filters = {}
elif filters.get("from_date") or filters.get("to_date"):
filters["from_time"] = "00:00:00"
filters["to_time"] = "24:00:00"
filters = filters or {}
columns = get_column()
conditions = get_conditions(filters)
data = get_data(conditions, filters)
data = get_data(filters)
return columns, data
@@ -36,30 +33,39 @@ def get_column():
]
def get_data(conditions, filters):
time_sheet = frappe.db.sql(
""" select `tabTimesheet`.name, `tabTimesheet`.employee, `tabTimesheet`.employee_name,
`tabTimesheet Detail`.from_time, `tabTimesheet Detail`.to_time, `tabTimesheet Detail`.hours,
`tabTimesheet Detail`.activity_type, `tabTimesheet Detail`.task, `tabTimesheet Detail`.project,
`tabTimesheet`.status from `tabTimesheet Detail`, `tabTimesheet` where
`tabTimesheet Detail`.parent = `tabTimesheet`.name and %s order by `tabTimesheet`.name"""
% (conditions),
filters,
as_list=1,
def get_data(filters):
ts = frappe.qb.DocType("Timesheet")
tsd = frappe.qb.DocType("Timesheet Detail")
# Base the query on Timesheet so get_query applies its user-permission match conditions
# (the qb form of build_match_conditions); Timesheet Detail rows are inner-joined on.
query = (
frappe.qb.get_query(
"Timesheet",
fields=["name", "employee", "employee_name"],
ignore_permissions=False,
)
.inner_join(tsd)
.on(tsd.parent == ts.name)
.select(
tsd.from_time,
tsd.to_time,
tsd.hours,
tsd.activity_type,
tsd.task,
tsd.project,
ts.status,
)
.where(ts.docstatus == 1)
)
return time_sheet
def get_conditions(filters):
conditions = "`tabTimesheet`.docstatus = 1"
if filters.get("from_date"):
conditions += " and `tabTimesheet Detail`.from_time >= timestamp(%(from_date)s, %(from_time)s)"
query = query.where(tsd.from_time >= get_combine_datetime(filters.get("from_date"), "00:00:00"))
if filters.get("to_date"):
conditions += " and `tabTimesheet Detail`.to_time <= timestamp(%(to_date)s, %(to_time)s)"
# upper bound is the end of to_date, i.e. midnight of the next day
# (matches the original `timestamp(to_date, '24:00:00')`)
end_of_to_date = get_combine_datetime(add_days(getdate(filters.get("to_date")), 1), "00:00:00")
query = query.where(tsd.to_time <= end_of_to_date)
match_conditions = build_match_conditions("Timesheet")
if match_conditions:
conditions += " and (%s)" % match_conditions
return conditions
return query.orderby(ts.name).run(as_list=True)

View File

@@ -0,0 +1,26 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
from erpnext.projects.report.daily_timesheet_summary.daily_timesheet_summary import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestDailyTimesheetSummary(ERPNextTestSuite):
def test_submitted_timesheet_in_summary(self):
frappe.set_user("Administrator")
employee = make_employee("test_employee_6@salary.com", company="_Test Company")
timesheet = make_timesheet(employee, simulate=True)
_columns, data = execute({"from_date": today(), "to_date": today()})
# Row column order: [Timesheet.name, employee, employee_name, from_time, to_time,
# hours, activity_type, task, project, status]. The converted join must surface the
# submitted timesheet for today; row[0] holds the Timesheet name.
names = [row[0] for row in data]
self.assertIn(timesheet.name, names)

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
def execute(filters=None):
@@ -50,19 +51,28 @@ def get_columns():
def get_project_details():
return frappe.db.sql(
""" select name, project_name, status, company, customer, estimated_costing,
expected_start_date, expected_end_date from tabProject where docstatus < 2""",
as_dict=1,
return frappe.get_all(
"Project",
filters={"docstatus": ["<", 2]},
fields=[
"name",
"project_name",
"status",
"company",
"customer",
"estimated_costing",
"expected_start_date",
"expected_end_date",
],
)
def get_purchased_items_cost():
pr_items = frappe.db.sql(
"""select project, sum(base_net_amount) as amount
from `tabPurchase Receipt Item` where ifnull(project, '') != ''
and docstatus = 1 group by project""",
as_dict=1,
pr_items = frappe.get_all(
"Purchase Receipt Item",
filters={"project": ["is", "set"], "docstatus": 1},
fields=["project", {"SUM": "base_net_amount", "as": "amount"}],
group_by="project",
)
pr_item_map = {}
@@ -73,12 +83,20 @@ def get_purchased_items_cost():
def get_issued_items_cost():
se_items = frappe.db.sql(
"""select se.project, sum(se_item.amount) as amount
from `tabStock Entry` se, `tabStock Entry Detail` se_item
where se.name = se_item.parent and se.docstatus = 1 and ifnull(se_item.t_warehouse, '') = ''
and se.project != '' group by se.project""",
as_dict=1,
se = frappe.qb.DocType("Stock Entry")
se_item = frappe.qb.DocType("Stock Entry Detail")
se_items = (
frappe.qb.from_(se)
.inner_join(se_item)
.on(se.name == se_item.parent)
.select(se.project, Sum(se_item.amount).as_("amount"))
.where(
(se.docstatus == 1)
& (se_item.t_warehouse.isnull() | (se_item.t_warehouse == ""))
& (se.project != "")
)
.groupby(se.project)
.run(as_dict=1)
)
se_item_map = {}
@@ -89,21 +107,28 @@ def get_issued_items_cost():
def get_delivered_items_cost():
dn_items = frappe.db.sql(
"""select dn.project, sum(dn_item.base_net_amount) as amount
from `tabDelivery Note` dn, `tabDelivery Note Item` dn_item
where dn.name = dn_item.parent and dn.docstatus = 1 and ifnull(dn.project, '') != ''
group by dn.project""",
as_dict=1,
dn = frappe.qb.DocType("Delivery Note")
dn_item = frappe.qb.DocType("Delivery Note Item")
dn_items = (
frappe.qb.from_(dn)
.inner_join(dn_item)
.on(dn.name == dn_item.parent)
.select(dn.project, Sum(dn_item.base_net_amount).as_("amount"))
.where((dn.docstatus == 1) & (dn.project != ""))
.groupby(dn.project)
.run(as_dict=1)
)
si_items = frappe.db.sql(
"""select si.project, sum(si_item.base_net_amount) as amount
from `tabSales Invoice` si, `tabSales Invoice Item` si_item
where si.name = si_item.parent and si.docstatus = 1 and si.update_stock = 1
and si.is_pos = 1 and ifnull(si.project, '') != ''
group by si.project""",
as_dict=1,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
si_items = (
frappe.qb.from_(si)
.inner_join(si_item)
.on(si.name == si_item.parent)
.select(si.project, Sum(si_item.base_net_amount).as_("amount"))
.where((si.docstatus == 1) & (si.update_stock == 1) & (si.is_pos == 1) & (si.project != ""))
.groupby(si.project)
.run(as_dict=1)
)
dn_item_map = {}

View File

@@ -0,0 +1,78 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, random_string, today
from erpnext.projects.report.project_wise_stock_tracking.project_wise_stock_tracking import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectWiseStockTracking(ERPNextTestSuite):
def test_project_wise_stock_tracking(self):
project = frappe.get_doc(
{
"doctype": "Project",
"project_name": "_Test PWST " + random_string(10),
"status": "Open",
"company": "_Test Company",
}
).insert()
# Issued cost: a project-tagged Material Issue (t_warehouse empty) -> get_issued_items_cost.
make_stock_entry(item_code="_Test Item", qty=10, to_warehouse="_Test Warehouse - _TC", rate=100)
issue = make_stock_entry(
item_code="_Test Item", qty=4, from_warehouse="_Test Warehouse - _TC", do_not_save=True
)
issue.project = project.name
issue.save()
issue.submit()
expected_issued_cost = issue.items[0].amount
# Purchased cost: a submitted Purchase Receipt Item tagged to the project. Inserted directly
# (no parent receipt) so get_purchased_items_cost has data without running Purchase Receipt
# validation (which would also pull in the landed-cost-voucher path).
self.make_child_row("Purchase Receipt Item", "Purchase Receipt", 300, project=project.name)
# Delivered cost: a submitted Delivery Note + line; the report joins on the parent's project.
dn = self.make_parent_row("Delivery Note", company="_Test Company", customer="_Test Customer")
frappe.db.set_value("Delivery Note", dn, "project", project.name)
self.make_child_row("Delivery Note Item", "Delivery Note", 200, parent=dn)
_columns, data = execute(filters=None)
row = next((r for r in data if r[0] == project.name), None)
# get_project_details must surface the freshly created project.
self.assertIsNotNone(row, "Project row missing from report output")
self.assertEqual(flt(row[1]), 300) # get_purchased_items_cost (GROUP BY project)
self.assertEqual(flt(row[2]), flt(expected_issued_cost)) # get_issued_items_cost
self.assertEqual(flt(row[3]), 200) # get_delivered_items_cost
def make_parent_row(self, doctype, **fields):
doc = frappe.new_doc(doctype)
for key, value in fields.items():
doc.set(key, value)
doc.posting_date = today()
doc.docstatus = 1
doc.flags.name_set = True
doc.name = frappe.generate_hash("pwst", 12)
doc.db_insert()
return doc.name
def make_child_row(self, doctype, parenttype, base_net_amount, project=None, parent=None):
row = frappe.new_doc(doctype)
row.parenttype = parenttype
row.parentfield = "items"
row.parent = parent or frappe.generate_hash("pwst", 12)
row.idx = 1
row.item_code = "_Test Item"
row.item_name = "_Test Item"
row.base_net_amount = base_net_amount
if project:
row.project = project
row.docstatus = 1
row.flags.name_set = True
row.name = frappe.generate_hash("pwst", 12)
row.db_insert()
return row.name

View File

@@ -5,28 +5,25 @@
import frappe
from frappe.query_builder import Case
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def query_task(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
from frappe.desk.reportview import build_match_conditions
search_str = f"%{txt}%"
prefix_str = f"{txt}%"
search_string = "%%%s%%" % txt
order_by_string = "%s%%" % txt
match_conditions = build_match_conditions("Task")
match_conditions = (f"and ({match_conditions})") if match_conditions else ""
Task = frappe.qb.DocType("Task")
query = frappe.qb.get_query("Task", fields=["name", "subject"], ignore_permissions=False)
return frappe.db.sql(
"""select name, subject from `tabTask`
where (`{}` like {} or `subject` like {}) {}
order by
case when `subject` like {} then 0 else 1 end,
case when `{}` like {} then 0 else 1 end,
`{}`,
subject
limit {} offset {}""".format(
searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"
),
(search_string, search_string, order_by_string, order_by_string, page_len, start),
return (
query.where(Task[searchfield].like(search_str) | Task.subject.like(search_str))
.orderby(Case().when(Task.subject.like(prefix_str), 0).else_(1))
.orderby(Case().when(Task[searchfield].like(prefix_str), 0).else_(1))
.orderby(Task[searchfield])
.orderby(Task.subject)
.limit(page_len)
.offset(start)
.run()
)

View File

@@ -602,6 +602,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
refresh() {
erpnext.toggle_naming_series();
erpnext.hide_company(this.frm);
// Remember the currency the rendered document is denominated in, so that a
// real currency change can be told apart from a mere exchange rate refresh
// (e.g. triggered by a date change).
this._doc_currency = this.frm.doc.currency;
this.set_dynamic_labels();
this.setup_sms();
this.setup_quality_inspection();
@@ -1472,6 +1476,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let me = this;
this.set_dynamic_labels();
let company_currency = this.get_company_currency();
// Currency the stored margins/actual charges are denominated in, captured
// before this trigger updates the tracker for the next one.
let previous_currency = this._doc_currency;
this._doc_currency = this.frm.doc.currency;
// Added `load_after_mapping` to determine if document is loading after mapping from another doc
if (
this.frm.doc.currency &&
@@ -1484,8 +1492,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
company_currency,
function (exchange_rate) {
if (exchange_rate != me.frm.doc.conversion_rate) {
me.set_margin_amount_based_on_currency(exchange_rate);
me.set_actual_charges_based_on_currency(exchange_rate);
// Margins and actual charges are amounts in the transaction
// currency; convert them only when the currency itself changed,
// not when just the exchange rate was refreshed (e.g. by a date
// change), otherwise the entered margin keeps shrinking.
if (previous_currency !== me.frm.doc.currency) {
me.set_margin_amount_based_on_currency(exchange_rate);
me.set_actual_charges_based_on_currency(exchange_rate);
}
me.frm.set_value("conversion_rate", exchange_rate);
}
}

View File

@@ -5,6 +5,8 @@ import json
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, nowdate
from frappe.utils.data import fmt_money
from frappe.utils.jinja import render_template
@@ -29,37 +31,34 @@ def execute(filters=None):
return [], []
columns = get_columns()
conditions = ""
if filters.supplier_group:
conditions += "AND s.supplier_group = %s" % frappe.db.escape(filters.get("supplier_group"))
data = frappe.db.sql(
f"""
SELECT
s.supplier_group as "supplier_group",
gl.party AS "supplier",
s.tax_id as "tax_id",
SUM(gl.debit_in_account_currency) AS "payments"
FROM
`tabGL Entry` gl
INNER JOIN `tabSupplier` s
WHERE
s.name = gl.party
AND s.irs_1099 = 1
AND gl.fiscal_year = %(fiscal_year)s
AND gl.party_type = 'Supplier'
AND gl.company = %(company)s
{conditions}
GROUP BY
gl.party
ORDER BY
gl.party DESC""",
{"fiscal_year": filters.fiscal_year, "company": filters.company},
as_dict=True,
gl = frappe.qb.DocType("GL Entry")
s = frappe.qb.DocType("Supplier")
query = (
frappe.qb.from_(gl)
.inner_join(s)
.on(s.name == gl.party)
.select(
s.supplier_group.as_("supplier_group"),
gl.party.as_("supplier"),
s.tax_id.as_("tax_id"),
Sum(gl.debit_in_account_currency).as_("payments"),
)
.where(
(s.irs_1099 == 1)
& (gl.fiscal_year == filters.fiscal_year)
& (gl.party_type == "Supplier")
& (gl.company == filters.company)
)
.groupby(gl.party, s.supplier_group, s.tax_id)
.orderby(gl.party, order=frappe.qb.desc)
)
if filters.supplier_group:
query = query.where(s.supplier_group == filters.supplier_group)
data = query.run(as_dict=True)
return columns, data
@@ -125,20 +124,15 @@ def irs_1099_print(filters: str):
def get_payer_address_html(company):
address_list = frappe.db.sql(
"""
SELECT
name
FROM
tabAddress
WHERE
is_your_company_address = 1
ORDER BY
address_type="Postal" DESC, address_type="Billing" DESC
LIMIT 1
""",
{"company": company},
as_dict=True,
address = frappe.qb.DocType("Address")
address_list = (
frappe.qb.from_(address)
.select(address.name)
.where(address.is_your_company_address == 1)
.orderby(Case().when(address.address_type == "Postal", 1).else_(0), order=frappe.qb.desc)
.orderby(Case().when(address.address_type == "Billing", 1).else_(0), order=frappe.qb.desc)
.limit(1)
.run(as_dict=True)
)
address_display = ""
@@ -150,23 +144,19 @@ def get_payer_address_html(company):
def get_street_address_html(party_type, party):
address_list = frappe.db.sql(
"""
SELECT
link.parent
FROM
`tabDynamic Link` link,
`tabAddress` address
WHERE
link.parenttype = "Address"
AND link.link_name = %(party)s
ORDER BY
address.address_type="Postal" DESC,
address.address_type="Billing" DESC
LIMIT 1
""",
{"party": party},
as_dict=True,
link = frappe.qb.DocType("Dynamic Link")
address = frappe.qb.DocType("Address")
address_list = (
frappe.qb.from_(link)
.inner_join(address)
.on(address.name == link.parent)
.select(link.parent)
.where((link.parenttype == "Address") & (link.link_name == party))
.orderby(Case().when(address.address_type == "Postal", 1).else_(0), order=frappe.qb.desc)
.orderby(Case().when(address.address_type == "Billing", 1).else_(0), order=frappe.qb.desc)
.orderby(link.parent) # deterministic LIMIT-1 tie-break across engines
.limit(1)
.run(as_dict=True)
)
street_address = city_state = ""

View File

@@ -0,0 +1,42 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.regional.report.irs_1099.irs_1099 import get_street_address_html
from erpnext.tests.utils import ERPNextTestSuite
class TestIRS1099StreetAddress(ERPNextTestSuite):
def test_street_address_prefers_postal(self):
"""The original query cross-joined Address with no join predicate, so its
`ORDER BY address_type='Postal' DESC` sorted on an arbitrary cross-joined row and never
controlled which link.parent (Address) was returned. The conversion joins address.name ==
link.parent so the Postal/Billing preference actually applies; a `link.parent` tie-break keeps
the LIMIT-1 pick deterministic across engines when several addresses share the top type."""
party = "_Test 1099 Address Supplier"
if not frappe.db.exists("Supplier", party):
frappe.get_doc(
{"doctype": "Supplier", "supplier_name": party, "supplier_group": "_Test Supplier Group"}
).insert(ignore_permissions=True)
def mk_addr(title, address_type, line1):
frappe.get_doc(
{
"doctype": "Address",
"address_title": title,
"address_type": address_type,
"address_line1": line1,
"city": "Testville",
"country": "United States",
"links": [{"link_doctype": "Supplier", "link_name": party}],
}
).insert(ignore_permissions=True)
mk_addr("_Test 1099 Billing", "Billing", "1 Billing St")
mk_addr("_Test 1099 Postal", "Postal", "9 Postal Rd")
street, _city_state = get_street_address_html("Supplier", party)
# the Postal address must win over the Billing one (deterministically, on both engines)
self.assertIn("9 Postal Rd", street)
self.assertNotIn("1 Billing St", street)

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