mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-06 01:50:21 +00:00
Compare commits
18 Commits
version-16
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853ac859ca | ||
|
|
bb26f8f7b9 | ||
|
|
074f9f0828 | ||
|
|
31319bd36e | ||
|
|
8229aeaead | ||
|
|
d48bb40969 | ||
|
|
82392fa747 | ||
|
|
48983c5ef0 | ||
|
|
d23b407ec7 | ||
|
|
770726d8f5 | ||
|
|
e5b1ff667d | ||
|
|
d8236548be | ||
|
|
f75601e9b1 | ||
|
|
0684599bdb | ||
|
|
a1c8dc878d | ||
|
|
dfb64d7635 | ||
|
|
b0ddca0455 | ||
|
|
2cc1a51d9a |
30
.github/helper/install.sh
vendored
30
.github/helper/install.sh
vendored
@@ -4,6 +4,36 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
|
||||
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
|
||||
# below, or payments under `bench get-app`. See the PR description.
|
||||
#
|
||||
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
|
||||
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
|
||||
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
|
||||
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
|
||||
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
|
||||
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
|
||||
if [ -n "$ci_github_token" ]; then
|
||||
export CI_GITHUB_TOKEN="$ci_github_token"
|
||||
export GIT_CONFIG_COUNT=3
|
||||
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
|
||||
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
|
||||
# token to ~/.git-credentials. An empty value clears the list before ours is added.
|
||||
export GIT_CONFIG_KEY_0="credential.helper"
|
||||
export GIT_CONFIG_VALUE_0=""
|
||||
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
|
||||
export GIT_CONFIG_VALUE_1="x-access-token"
|
||||
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
|
||||
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
|
||||
# token is read from the environment at call time and never stored anywhere. Answering only
|
||||
# `get` makes the helper inert for git's `store`/`erase` calls.
|
||||
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
|
||||
fi
|
||||
|
||||
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -105,6 +105,8 @@ jobs:
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,6 +129,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/server-tests-mariadb.yml
vendored
2
.github/workflows/server-tests-mariadb.yml
vendored
@@ -102,6 +102,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.34.1"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"modified": "2025-12-19 12:37:31.673782",
|
||||
"modified": "2026-09-04 12:37:31.673782",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss",
|
||||
@@ -17,7 +17,6 @@
|
||||
"owner": "Administrator",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"roles": [],
|
||||
"show_values_over_chart": 1,
|
||||
"timeseries": 0,
|
||||
"type": "Line",
|
||||
"use_report_chart": 1,
|
||||
|
||||
@@ -916,7 +916,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
|
||||
|
||||
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
|
||||
|
||||
if not days:
|
||||
if days is None:
|
||||
days = 3
|
||||
|
||||
min_date = frappe.utils.add_days(date, -days)
|
||||
|
||||
@@ -193,8 +193,10 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for {1}").format(
|
||||
get_formula_field_label(row.data_source), row.data_source
|
||||
message=_("{0} is required when {1} is {2}").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
row.meta.get_translated_label("data_source"),
|
||||
_(row.data_source),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -222,7 +224,14 @@ class DependencyValidator(Validator):
|
||||
|
||||
for row in self.template.rows:
|
||||
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
|
||||
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
|
||||
# skip self-reference, `CalculationFormulaValidator` already reports it
|
||||
deps = [
|
||||
code
|
||||
for code in extract_reference_codes_from_formula(
|
||||
row.calculation_formula, list(available_codes)
|
||||
)
|
||||
if code != row.reference_code
|
||||
]
|
||||
if deps:
|
||||
graph[row.reference_code] = deps
|
||||
|
||||
@@ -284,7 +293,9 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -311,17 +322,6 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.data_source != "Calculated Amount":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Calculated Amount").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
@@ -346,16 +346,6 @@ class CalculationFormulaValidator(Validator):
|
||||
)
|
||||
)
|
||||
|
||||
# Check undefined references
|
||||
undefined = set(refs) - set(available_codes)
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Try to evaluate with dummy values
|
||||
eval_error = self._test_formula_evaluation(formula, available_codes)
|
||||
if eval_error:
|
||||
@@ -418,17 +408,6 @@ class AccountFilterValidator(Validator):
|
||||
if row.data_source != "Account Data":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Account Data").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
filter_config = json.loads(row.calculation_formula)
|
||||
error = self._validate_filter_structure(
|
||||
@@ -440,7 +419,9 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source), error
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -448,8 +429,9 @@ class AccountFilterValidator(Validator):
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: Invalid JSON format: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
_("Invalid JSON format: {0}").format(str(e)),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -555,8 +537,9 @@ class FormulaValidator(Validator):
|
||||
frappe.clear_last_message()
|
||||
|
||||
if isinstance(e, frappe.PermissionError):
|
||||
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
|
||||
get_formula_field_label(row.data_source), api_path
|
||||
message = _("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
|
||||
)
|
||||
else:
|
||||
message = _("Could not validate {0}: {1}").format(
|
||||
|
||||
@@ -237,7 +237,9 @@ class Lead(SellingController, CRMNote):
|
||||
return frappe.db.get_value("Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"})
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_prospect_and_contact(self, data):
|
||||
def create_prospect_and_contact(self, data: dict):
|
||||
self.check_permission("write")
|
||||
|
||||
data = frappe._dict(data)
|
||||
if data.create_contact:
|
||||
self.create_contact()
|
||||
@@ -526,8 +528,11 @@ def get_lead_with_phone_number(number):
|
||||
return lead
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_lead_to_prospect(lead, prospect):
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
if lead:
|
||||
frappe.has_permission("Lead", "read", lead, throw=True)
|
||||
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
prospect.save()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
@@ -78,8 +79,48 @@ class CodeList(Document):
|
||||
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
|
||||
|
||||
|
||||
def _version_key(version: str | None) -> list:
|
||||
"""Natural sort key for the version formats publishers use: integers and ISO dates.
|
||||
|
||||
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
|
||||
"""
|
||||
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def resolve_code_list(code_list: str) -> str | None:
|
||||
"""Return the Code List for a document name or a canonical URI.
|
||||
|
||||
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
|
||||
map to several documents, one per version. An exact document name takes
|
||||
precedence, which lets a caller request a specific version; a canonical URI
|
||||
resolves to the latest version available.
|
||||
"""
|
||||
if frappe.db.exists("Code List", code_list):
|
||||
return code_list
|
||||
|
||||
candidates = frappe.get_all(
|
||||
"Code List",
|
||||
filters={"canonical_uri": code_list},
|
||||
fields=["name", "version"],
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# ponytail: assumes one publisher sticks to one version format. An integer and an
|
||||
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
|
||||
# would win; import the genericode ValidityDate and sort on that if it ever happens.
|
||||
return max(candidates, key=lambda cl: _version_key(cl.version)).name
|
||||
|
||||
|
||||
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
"""Return the common code for a given record"""
|
||||
"""Return the common code for a given record.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
"""Return the record name for a given common code"""
|
||||
"""Return the record name for a given common code.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_default_code(code_list: str) -> str | None:
|
||||
"""Return the default common code for a given code list"""
|
||||
"""Return the default common code for a given code list.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return None
|
||||
|
||||
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
|
||||
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
|
||||
from erpnext.edi.doctype.code_list.code_list import (
|
||||
_version_key,
|
||||
get_codes_for,
|
||||
get_default_code,
|
||||
get_docnames_for,
|
||||
resolve_code_list,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
|
||||
OLD_VERSION = f"{CANONICAL_URI}:3"
|
||||
NEW_VERSION = f"{CANONICAL_URI}:10"
|
||||
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
|
||||
|
||||
|
||||
class TestCodeList(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
"""Create two versions of one code list. Test records are rolled back per test."""
|
||||
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
|
||||
if not frappe.db.exists("Code List", name):
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=name,
|
||||
title=name,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version=version,
|
||||
).insert()
|
||||
|
||||
default_code = frappe.get_doc(
|
||||
doctype="Common Code",
|
||||
title="Test Default",
|
||||
common_code="XYZ",
|
||||
code_list=NEW_VERSION,
|
||||
).insert()
|
||||
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
|
||||
|
||||
# resolution is request-cached, so fixtures must not be masked by earlier lookups
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
def test_version_key_orders_integers_and_iso_dates(self):
|
||||
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
|
||||
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
|
||||
self.assertEqual(
|
||||
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
|
||||
["2019-12-31", "2020-01-01", "2020-11-05"],
|
||||
)
|
||||
|
||||
def test_canonical_uri_resolves_to_latest_version(self):
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
|
||||
|
||||
def test_name_resolves_to_itself(self):
|
||||
"""Passing a version-specific name must return that version, not the latest one."""
|
||||
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
|
||||
|
||||
def test_name_takes_precedence_over_canonical_uri(self):
|
||||
"""A document named like a canonical URI must not redirect to another version."""
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=CANONICAL_URI,
|
||||
title=CANONICAL_URI,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version="1",
|
||||
).insert()
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
|
||||
|
||||
def test_unknown_uri_resolves_to_none(self):
|
||||
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
|
||||
|
||||
def test_lookups_are_empty_for_unknown_code_list(self):
|
||||
"""An unresolved code list must not fall through to an unfiltered query."""
|
||||
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
|
||||
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
|
||||
self.assertIsNone(get_default_code(UNKNOWN_URI))
|
||||
|
||||
def test_default_code_follows_latest_version(self):
|
||||
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")
|
||||
|
||||
@@ -318,43 +318,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
# transfer was made for 2 fg qty in first transfer Stock Entry
|
||||
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
|
||||
|
||||
def test_material_request_stock_entry_uses_job_card_coverage(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
|
||||
|
||||
self.transfer_material_against = "Job Card"
|
||||
self.source_warehouse = "Stores - _TC"
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
mr = make_material_request(job_card.name)
|
||||
mr.schedule_date = today()
|
||||
for row in mr.items:
|
||||
row.qty = flt(row.qty) / 2
|
||||
row.stock_qty = flt(row.stock_qty) / 2
|
||||
mr.submit()
|
||||
|
||||
stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
|
||||
|
||||
selected_row = mr.items[0]
|
||||
try:
|
||||
frappe.flags.selected_children = {"items": [selected_row.name]}
|
||||
selected_stock_entry = make_stock_entry(mr.name)
|
||||
finally:
|
||||
frappe.flags.selected_children = None
|
||||
|
||||
self.assertEqual(
|
||||
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
|
||||
)
|
||||
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
|
||||
|
||||
for row in mr.items:
|
||||
transferred_qty = flt(row.stock_qty) / 2
|
||||
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
|
||||
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
|
||||
mr.reload()
|
||||
|
||||
repeated_stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
|
||||
def test_job_card_excess_material_transfer(self):
|
||||
"Test transferring more than required RM against Job Card."
|
||||
@@ -768,7 +731,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(ste.job_card, job_card_name)
|
||||
self.assertEqual(ste.from_bom, 1.0)
|
||||
self.assertEqual(ste.bom_no, work_order.bom_no)
|
||||
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
|
||||
|
||||
def test_job_card_material_transfer_via_pick_list(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import create_pick_list
|
||||
|
||||
@@ -687,7 +687,9 @@ class ProductionPlan(Document):
|
||||
frappe.delete_doc("Work Order", d.name)
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_status(self, close=None, update_bin=False):
|
||||
def set_status(self, close: bool | None = None, update_bin: bool = False):
|
||||
self.check_permission("write")
|
||||
|
||||
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
|
||||
|
||||
if close:
|
||||
|
||||
@@ -3167,6 +3167,46 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
"The phantom BOM was not re-exploded for the second po_item.",
|
||||
)
|
||||
|
||||
def test_set_status_requires_write_permission(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
|
||||
with self.set_user(create_user_without_production_plan_access()):
|
||||
doc = frappe.get_doc("Production Plan", pln.name)
|
||||
self.assertRaises(frappe.PermissionError, doc.set_status)
|
||||
|
||||
def test_work_order_status_rollup_without_production_plan_permission(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
pln.make_work_order()
|
||||
|
||||
wo_name = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name")
|
||||
frappe.db.set_value("Production Plan Item", pln.po_items[0].name, "ordered_qty", 99)
|
||||
|
||||
with self.set_user(create_user_without_production_plan_access()):
|
||||
frappe.get_doc("Work Order", wo_name).update_ordered_qty()
|
||||
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 0.0)
|
||||
self.assertEqual(pln.status, "Submitted")
|
||||
|
||||
def test_material_request_status_rollup_without_production_plan_permission(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
pln.make_material_request()
|
||||
|
||||
plan_item = pln.mr_items[0].name
|
||||
mr_name = frappe.db.get_value(
|
||||
"Material Request Item", {"material_request_plan_item": plan_item}, "parent"
|
||||
)
|
||||
frappe.get_doc("Material Request", mr_name).submit()
|
||||
frappe.db.set_value("Material Request Plan Item", plan_item, "requested_qty", 0)
|
||||
|
||||
with self.set_user(create_user_without_production_plan_access()):
|
||||
frappe.get_doc("Material Request", mr_name).update_requested_qty_in_production_plan()
|
||||
|
||||
pln.reload()
|
||||
requested_qty = frappe.db.get_value("Material Request Plan Item", plan_item, "requested_qty")
|
||||
self.assertGreater(requested_qty, 0)
|
||||
self.assertEqual(pln.status, "Material Requested")
|
||||
|
||||
|
||||
def create_production_plan(**args):
|
||||
"""
|
||||
@@ -3299,3 +3339,19 @@ def make_bom(**args):
|
||||
frappe.set_value("Item", args.item, "default_bom", bom.name)
|
||||
|
||||
return bom
|
||||
|
||||
|
||||
def create_user_without_production_plan_access():
|
||||
user = "test_production_plan_no_access@example.com"
|
||||
if not frappe.db.exists("User", user):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": user,
|
||||
"first_name": "Production Plan No Access",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"doctype": "Has Role", "role": "Stock User"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return user
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_minimum_material_coverage_fraction(
|
||||
required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int
|
||||
) -> float:
|
||||
"""Return the least-covered component ratio at the configured quantity precision."""
|
||||
coverage = []
|
||||
for item_code, required in required_qty.items():
|
||||
transferred = flt(transferred_qty.get(item_code))
|
||||
# Stored values can differ after the digits that the user can enter or see.
|
||||
if flt(transferred, precision) == flt(required, precision):
|
||||
coverage.append(1.0)
|
||||
else:
|
||||
coverage.append(transferred / required)
|
||||
|
||||
return min(coverage, default=0.0)
|
||||
@@ -1464,11 +1464,9 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
del transfer_entry.get("items")[0] # transfer only one RM
|
||||
transfer_entry.submit()
|
||||
|
||||
# One required item is still missing, so no finished-good quantity is covered yet.
|
||||
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
|
||||
work_order.reload()
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 0)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
@@ -1488,47 +1486,6 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
def test_material_transfer_claim_follows_actual_coverage(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=20,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
|
||||
)
|
||||
for row in transfer_entry.items:
|
||||
if row.item_code == "_Test Item":
|
||||
row.qty = 1
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 1)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
|
||||
remainder_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
|
||||
)
|
||||
remainder_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(remainder_entry.fg_completed_qty, 3)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 4)
|
||||
|
||||
def test_material_coverage_cap_skips_manufacture_entry(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
|
||||
manufacture_entry.pro_doc = work_order
|
||||
manufacture_entry._action = "submit"
|
||||
|
||||
self.assertFalse(manufacture_entry._should_cap_completed_qty())
|
||||
|
||||
def test_material_transferred_min_fraction_on_partial_pick_list(self):
|
||||
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
|
||||
must reflect the least-transferred required item (the bottleneck), instead of being
|
||||
@@ -1591,97 +1548,6 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_material_transferred_ignores_hidden_precision_difference(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
precision = work_order.precision("required_qty", "required_items")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
row = work_order.required_items[0]
|
||||
row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
|
||||
work_order.reload()
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
for item in transfer_entry.items:
|
||||
item.qty = flt(required_qty[item.item_code], precision)
|
||||
item.transfer_qty = item.qty
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(
|
||||
flt(work_order.required_items[0].required_qty, precision),
|
||||
flt(work_order.required_items[0].transferred_qty, precision),
|
||||
)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
|
||||
def test_repair_material_transfer_precision_patch(self):
|
||||
from erpnext.patches.v16_0.repair_work_order_material_transfer import (
|
||||
execute,
|
||||
get_precision_affected_work_orders,
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for index, row in enumerate(work_order.required_items):
|
||||
required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
|
||||
row.db_set(
|
||||
{
|
||||
"required_qty": required_qty,
|
||||
"transferred_qty": flt(required_qty, precision),
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in partial_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
partial_row = partial_work_order.required_items[0]
|
||||
partial_row.db_set(
|
||||
"transferred_qty",
|
||||
flt(partial_row.required_qty, precision) - (1 / (10**precision)),
|
||||
update_modified=False,
|
||||
)
|
||||
partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
terminal_work_orders = []
|
||||
for status in ("Stopped", "Closed", "Completed"):
|
||||
terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in terminal_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
terminal_work_order.db_set(
|
||||
{"material_transferred_for_manufacturing": 1.99, "status": status},
|
||||
update_modified=False,
|
||||
)
|
||||
terminal_work_orders.append(terminal_work_order)
|
||||
|
||||
updates = get_precision_affected_work_orders()
|
||||
self.assertIn(work_order.name, updates)
|
||||
self.assertNotIn(partial_work_order.name, updates)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
self.assertNotIn(terminal_work_order.name, updates)
|
||||
|
||||
execute()
|
||||
work_order.reload()
|
||||
partial_work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
terminal_work_order.reload()
|
||||
self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
|
||||
def test_work_order_material_request_and_bom_details(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_stock_entry as mr_to_stock_entry,
|
||||
|
||||
@@ -34,9 +34,6 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||
get_mins_between_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
||||
@@ -745,9 +742,29 @@ class WorkOrder(Document):
|
||||
return status
|
||||
|
||||
def _has_transferred_material(self):
|
||||
"""True if any raw material transferred against this work order is still in WIP."""
|
||||
"""True if any raw material transferred against this work order via a pick list or a
|
||||
material request is still, net of returns, in WIP (these leave
|
||||
material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
mr_ste = frappe.qb.DocType("Stock Entry")
|
||||
mr_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
# Stock Entry only carries `material_request` at the child-row level, so a Stock
|
||||
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request against
|
||||
# this work order; the join to mr_ste keeps this scoped to this work order's entries
|
||||
# instead of scanning every Material-Request-linked row in the system.
|
||||
mr_sourced_stock_entries = (
|
||||
frappe.qb.from_(mr_child)
|
||||
.inner_join(mr_ste)
|
||||
.on(mr_ste.name == mr_child.parent)
|
||||
.select(mr_child.parent)
|
||||
.where(
|
||||
(mr_child.material_request.isnotnull())
|
||||
& (mr_ste.work_order == self.name)
|
||||
& (mr_ste.docstatus == 1)
|
||||
& (mr_ste.purpose == "Material Transfer for Manufacture")
|
||||
)
|
||||
)
|
||||
common_filters = (
|
||||
(ste.work_order == self.name)
|
||||
& (ste.docstatus == 1)
|
||||
@@ -758,7 +775,11 @@ class WorkOrder(Document):
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(common_filters & (ste.is_return == 0))
|
||||
.where(
|
||||
common_filters
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
|
||||
)
|
||||
).run()[0][0]
|
||||
# Returns don't carry their own pick_list/material_request reference, so net every
|
||||
# return against this work order to correctly clear WIP after a full return.
|
||||
@@ -903,6 +924,7 @@ class WorkOrder(Document):
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc("Production Plan", self.production_plan)
|
||||
production_plan.flags.ignore_permissions = True
|
||||
produced_qty = 0
|
||||
if self.production_plan_item:
|
||||
total_qty = frappe.get_all(
|
||||
@@ -1330,6 +1352,7 @@ class WorkOrder(Document):
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Production Plan", self.production_plan)
|
||||
doc.flags.ignore_permissions = True
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
@@ -1819,15 +1842,22 @@ class WorkOrder(Document):
|
||||
return transferred_items
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set transferred quantity from the raw materials that have actually moved."""
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
# Job Card transfers use the minimum completed quantity across operations.
|
||||
if self.operations and self.transfer_material_against == "Job Card":
|
||||
return
|
||||
|
||||
claimed_qty = self.get_transferred_or_manufactured_qty(
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty(
|
||||
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
|
||||
)
|
||||
if sum_fg_completed_qty:
|
||||
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||
return
|
||||
|
||||
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||
required_by_item = {}
|
||||
for row in self.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
@@ -1837,13 +1867,12 @@ class WorkOrder(Document):
|
||||
if not required_by_item:
|
||||
return
|
||||
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_by_item,
|
||||
transferred_items,
|
||||
self.precision("required_qty", "required_items"),
|
||||
min_fraction = min(
|
||||
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
)
|
||||
covered_qty = min_fraction * flt(self.qty)
|
||||
material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty))
|
||||
min_fraction = min(min_fraction, 1.0)
|
||||
material_transferred = min_fraction * flt(self.qty)
|
||||
self.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||
|
||||
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
|
||||
|
||||
@@ -500,7 +500,6 @@ erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
|
||||
erpnext.patches.v16_0.add_currency_to_blanket_orders
|
||||
erpnext.patches.v16_0.repair_work_order_material_transfer
|
||||
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
updates = get_precision_affected_work_orders()
|
||||
frappe.db.bulk_update("Work Order", updates, update_modified=False)
|
||||
|
||||
|
||||
def get_precision_affected_work_orders():
|
||||
"""Return Work Orders whose components cover the plan at quantity precision."""
|
||||
work_orders = {}
|
||||
for row in _get_candidate_rows():
|
||||
work_order = work_orders.setdefault(
|
||||
row.work_order,
|
||||
{"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}},
|
||||
)
|
||||
item_code = row.item_code
|
||||
work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt(
|
||||
row.required_qty
|
||||
)
|
||||
work_order["transferred_qty"][item_code] = max(
|
||||
work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
return {
|
||||
name: {"material_transferred_for_manufacturing": values["qty"]}
|
||||
for name, values in work_orders.items()
|
||||
if get_minimum_material_coverage_fraction(
|
||||
values["required_qty"], values["transferred_qty"], precision
|
||||
)
|
||||
>= 1.0
|
||||
}
|
||||
|
||||
|
||||
def _get_candidate_rows():
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
required_item = frappe.qb.DocType("Work Order Item")
|
||||
return (
|
||||
frappe.qb.from_(work_order)
|
||||
.inner_join(required_item)
|
||||
.on(required_item.parent == work_order.name)
|
||||
.select(
|
||||
work_order.name.as_("work_order"),
|
||||
work_order.qty,
|
||||
required_item.item_code,
|
||||
required_item.required_qty,
|
||||
required_item.transferred_qty,
|
||||
)
|
||||
.where(
|
||||
(work_order.docstatus == 1)
|
||||
& (work_order.status.notin(["Stopped", "Closed", "Completed"]))
|
||||
& (fn.Coalesce(work_order.skip_transfer, 0) == 0)
|
||||
& (fn.Coalesce(work_order.track_semi_finished_goods, 0) == 0)
|
||||
& (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty)
|
||||
& (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card")
|
||||
& (required_item.include_item_in_manufacturing == 1)
|
||||
& (required_item.required_qty > 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, now_datetime, nowdate
|
||||
@@ -9,12 +9,20 @@ from frappe.utils import add_to_date, now_datetime, nowdate
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.projects.doctype.task.test_task import create_task
|
||||
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
|
||||
from erpnext.projects.doctype.timesheet.timesheet import (
|
||||
OverlapError,
|
||||
get_projectwise_timesheet_data,
|
||||
make_sales_invoice,
|
||||
)
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestTimesheet(ERPNextTestSuite):
|
||||
def test_get_projectwise_timesheet_data_without_allowed_projects(self):
|
||||
with patch("frappe.get_list", side_effect=[["TS-0001"], []]):
|
||||
self.assertEqual(get_projectwise_timesheet_data(), [])
|
||||
|
||||
def test_timesheet_post_update(self):
|
||||
frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -335,10 +335,14 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to
|
||||
& (tsd.is_billable == 1)
|
||||
& tsd.sales_invoice.isnull()
|
||||
& (tsd.parent.isin(allowed_timesheets))
|
||||
& ((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
|
||||
)
|
||||
)
|
||||
|
||||
if allowed_projects:
|
||||
query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
|
||||
else:
|
||||
query = query.where(tsd.project.isnull())
|
||||
|
||||
if project:
|
||||
query = query.where(tsd.project == project)
|
||||
if parent:
|
||||
|
||||
@@ -157,6 +157,8 @@ class ImportSupplierInvoice(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def process_file_data(self):
|
||||
self.check_permission("write")
|
||||
|
||||
self.db_set("status", "Processing File Data", notify=True, commit=True)
|
||||
frappe.enqueue_doc(self.doctype, self.name, "import_xml_data", queue="long", timeout=3600)
|
||||
|
||||
|
||||
@@ -1082,6 +1082,8 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No
|
||||
@frappe.whitelist()
|
||||
def create_transaction_deletion_request(company):
|
||||
frappe.only_for("System Manager")
|
||||
# User Permission check
|
||||
frappe.has_permission("Company", ptype="delete", doc=company, throw=True)
|
||||
|
||||
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
|
||||
is_deletion_doc_running,
|
||||
@@ -1090,6 +1092,7 @@ def create_transaction_deletion_request(company):
|
||||
is_deletion_doc_running(company)
|
||||
|
||||
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
|
||||
tdr.flags.ignore_permissions = 1
|
||||
tdr.insert()
|
||||
|
||||
tdr.generate_to_delete_list()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "TDL.####",
|
||||
"creation": "2021-04-06 20:17:18.404716",
|
||||
"doctype": "DocType",
|
||||
@@ -166,19 +167,18 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-18 15:02:46.427695",
|
||||
"modified": "2026-09-02 20:32:19.679290",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Transaction Deletion Record",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
@@ -186,7 +186,6 @@
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
|
||||
@@ -157,6 +157,8 @@ class Batch(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def recalculate_batch_qty(self):
|
||||
self.check_permission("write")
|
||||
|
||||
batches = get_batch_qty(
|
||||
batch_no=self.name,
|
||||
item_code=self.item,
|
||||
|
||||
@@ -5,12 +5,12 @@ frappe.listview_settings["Batch"] = {
|
||||
return [__("Disabled"), "gray", "disabled,=,1"];
|
||||
} else if (
|
||||
doc.expiry_date &&
|
||||
frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) <= 0
|
||||
frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) < 0
|
||||
) {
|
||||
return [
|
||||
__("Expired"),
|
||||
"red",
|
||||
"expiry_date,not in,|expiry_date,<=,Today|batch_qty,>,0|disabled,=,0",
|
||||
"expiry_date,not in,|expiry_date,<,Today|batch_qty,>,0|disabled,=,0",
|
||||
];
|
||||
} else if (!doc.batch_qty) {
|
||||
return [__("Empty"), "gray", "batch_qty,=,0|disabled,=,0"];
|
||||
|
||||
@@ -101,8 +101,27 @@ frappe.ui.form.on("Material Request", {
|
||||
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
|
||||
if (!frm.doc.buying_price_list) {
|
||||
const buying_price_list = frappe.defaults.get_default("buying_price_list");
|
||||
if (frappe.has_permission("Price List", "read", buying_price_list)) {
|
||||
frm.set_value("buying_price_list", buying_price_list);
|
||||
if (buying_price_list) {
|
||||
const docname = frm.doc.name;
|
||||
frappe.call({
|
||||
type: "GET",
|
||||
method: "frappe.client.has_permission",
|
||||
no_spinner: true,
|
||||
args: {
|
||||
doctype: "Price List",
|
||||
docname: buying_price_list,
|
||||
perm_type: "read",
|
||||
},
|
||||
callback: ({ message }) => {
|
||||
if (
|
||||
message?.has_permission &&
|
||||
frm.doc.name === docname &&
|
||||
!frm.doc.buying_price_list
|
||||
) {
|
||||
frm.set_value("buying_price_list", buying_price_list);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -483,6 +483,7 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
for production_plan in production_plans:
|
||||
doc = frappe.get_doc("Production Plan", production_plan)
|
||||
doc.flags.ignore_permissions = True
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
@@ -999,12 +1000,8 @@ def make_stock_entry(source_name: str, target_doc: str | dict | None = None):
|
||||
target.bom_no = work_order_details.bom_no
|
||||
target.use_multi_level_bom = work_order_details.use_multi_level_bom
|
||||
target.from_bom = 1
|
||||
if not source.job_card:
|
||||
# not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order
|
||||
target.fg_completed_qty = 0
|
||||
|
||||
if source.job_card:
|
||||
target.cap_completed_qty_to_material_coverage()
|
||||
# not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order
|
||||
target.fg_completed_qty = 0
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Material Request",
|
||||
|
||||
@@ -277,6 +277,8 @@ class RepostItemValuation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def restart_reposting(self):
|
||||
self.check_permission("write")
|
||||
|
||||
self.set_status("Queued", write=False)
|
||||
self.current_index = 0
|
||||
self.distinct_item_and_warehouse = None
|
||||
|
||||
@@ -153,6 +153,8 @@ class StockClosingEntry(Document):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def enqueue_job(self):
|
||||
self.check_permission("write")
|
||||
|
||||
self.db_set("status", "In Progress")
|
||||
enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500)
|
||||
frappe.msgprint(
|
||||
@@ -163,6 +165,7 @@ class StockClosingEntry(Document):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def regenerate_closing_balance(self):
|
||||
self.check_permission("write")
|
||||
self.validate_closed_period_lock()
|
||||
self.remove_stock_closing()
|
||||
self.enqueue_job()
|
||||
@@ -189,7 +192,7 @@ class StockClosingEntry(Document):
|
||||
new_doc.posting_datetime = get_combine_datetime(self.to_date, new_doc.posting_time)
|
||||
new_doc.stock_closing_entry = self.name
|
||||
new_doc.company = self.company
|
||||
new_doc.save()
|
||||
new_doc.save(ignore_permissions=True)
|
||||
|
||||
def get_prepared_data(self):
|
||||
if attachments := get_attachments(self.doctype, self.name):
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.core.doctype.user_permission.test_user_permission import create_user
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
# On ERPNextTestSuite, the doctype test records and all
|
||||
# link-field test record depdendencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
class TestStockClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
@@ -16,4 +24,40 @@ class TestStockClosingEntry(ERPNextTestSuite):
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
def make_stock_closing_entry(self, from_date, to_date):
|
||||
entry = frappe.get_doc(
|
||||
doctype="Stock Closing Entry",
|
||||
company=COMPANY,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
).submit()
|
||||
self.last_closing_entry = entry.name
|
||||
return entry
|
||||
|
||||
def test_non_administrator_can_generate_closing_balance(self):
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
entry = self.make_stock_closing_entry(today(), today())
|
||||
|
||||
user = create_user("test_stock_closing_balance@example.com", "Stock User")
|
||||
self.assertFalse(frappe.has_permission("Stock Closing Balance", "create", user=user.name))
|
||||
|
||||
balance = frappe._dict(
|
||||
item_code=item,
|
||||
warehouse=WAREHOUSE,
|
||||
actual_qty=1,
|
||||
stock_value_difference=100,
|
||||
fifo_queue=None,
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.StockClosing"
|
||||
) as stock_closing,
|
||||
self.set_user(user.name),
|
||||
):
|
||||
stock_closing.return_value.get_stock_closing_entries.return_value = {(item, WAREHOUSE): balance}
|
||||
entry.create_stock_closing_balance_entries()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Stock Closing Balance", {"stock_closing_entry": entry.name, "item_code": item})
|
||||
)
|
||||
|
||||
@@ -33,9 +33,6 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
get_secondary_items_from_sub_assemblies,
|
||||
validate_bom_no,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
@@ -334,7 +331,6 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.calculate_rate_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_component_and_quantities()
|
||||
self._cap_completed_qty_to_material_coverage()
|
||||
self.validate_finished_good_serial_batch_for_work_order()
|
||||
# Stock Entry overrides validate() without calling super(), so the shared mandatory
|
||||
# inventory dimension check must be invoked explicitly here.
|
||||
@@ -1317,128 +1313,6 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
title=_("Missing Item"),
|
||||
)
|
||||
|
||||
def _cap_completed_qty_to_material_coverage(self):
|
||||
if not self._should_cap_completed_qty():
|
||||
return
|
||||
# Keep an excessive claim intact so the Work Order allowance check can reject it.
|
||||
max_qty = flt(self.pro_doc.qty)
|
||||
overproduction_percentage = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||
)
|
||||
extra_materials_percentage = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
|
||||
)
|
||||
to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt(
|
||||
self.fg_completed_qty
|
||||
)
|
||||
limit_percentage = extra_materials_percentage or overproduction_percentage
|
||||
transfer_limit_qty = max_qty + (max_qty * limit_percentage / 100)
|
||||
if transfer_limit_qty < to_transfer_qty:
|
||||
return
|
||||
|
||||
self.cap_completed_qty_to_material_coverage()
|
||||
|
||||
def cap_completed_qty_to_material_coverage(self):
|
||||
required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data()
|
||||
if not required_qty:
|
||||
return
|
||||
|
||||
covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
for row in self.items:
|
||||
if self.job_card:
|
||||
material_reference = row.job_card_item
|
||||
transferred = flt(row.qty)
|
||||
else:
|
||||
material_reference = row.original_item or row.item_code
|
||||
transferred = flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
|
||||
if material_reference in required_qty and (self.job_card or row.s_warehouse):
|
||||
transferred_qty[material_reference] += transferred
|
||||
|
||||
covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
|
||||
self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
|
||||
|
||||
def _should_cap_completed_qty(self):
|
||||
if self.get("_action") != "submit":
|
||||
return False
|
||||
if self.purpose != "Material Transfer for Manufacture":
|
||||
return False
|
||||
if not self.pro_doc or not self.fg_completed_qty:
|
||||
return False
|
||||
if self.is_return or self.get("is_additional_transfer_entry"):
|
||||
return False
|
||||
return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
|
||||
|
||||
def _get_material_coverage_data(self):
|
||||
if self.job_card:
|
||||
return self._get_job_card_material_qty()
|
||||
return self._get_work_order_material_qty()
|
||||
|
||||
def _get_job_card_material_qty(self):
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
for row in job_card.items:
|
||||
if flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_qty[row.name] = flt(row.required_qty)
|
||||
transferred_qty[row.name] = flt(row.transferred_qty)
|
||||
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self._get_job_card_target_qty(job_card),
|
||||
job_card.precision("required_qty", "items"),
|
||||
)
|
||||
|
||||
def _get_job_card_target_qty(self, job_card):
|
||||
required_by_item = {}
|
||||
for row in job_card.items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
|
||||
work_order_required_by_item = {}
|
||||
work_order = frappe.get_doc("Work Order", job_card.work_order)
|
||||
for row in work_order.required_items:
|
||||
if not (job_card.operation == row.operation or job_card.operation_row_id == row.operation_row_id):
|
||||
continue
|
||||
work_order_required_by_item[row.item_code] = work_order_required_by_item.get(
|
||||
row.item_code, 0.0
|
||||
) + flt(row.required_qty)
|
||||
|
||||
target_qty = [
|
||||
item_required * flt(work_order.qty) / work_order_required_by_item[item_code]
|
||||
for item_code, item_required in required_by_item.items()
|
||||
if work_order_required_by_item.get(item_code)
|
||||
]
|
||||
return min(target_qty) if target_qty else job_card.for_quantity
|
||||
|
||||
def _get_work_order_material_qty(self):
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
for row in self.pro_doc.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_qty[row.item_code] = required_qty.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
# Duplicate required-item rows each hold the aggregate transferred quantity.
|
||||
transferred_qty[row.item_code] = max(
|
||||
transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
)
|
||||
|
||||
def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision):
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
precision,
|
||||
)
|
||||
return min_fraction * flt(target_qty)
|
||||
|
||||
def _validate_no_excess_transfer(self):
|
||||
if self.is_return:
|
||||
return
|
||||
|
||||
@@ -73,6 +73,8 @@ class StockRepostingSettings(Document):
|
||||
def convert_to_item_wh_reposting(self):
|
||||
"""Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled."""
|
||||
|
||||
self.check_permission("write")
|
||||
|
||||
reposting_data = get_reposting_entries()
|
||||
|
||||
vouchers = [d.voucher_no for d in reposting_data]
|
||||
|
||||
Reference in New Issue
Block a user