Merge pull request #40820 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Deepesh Garg
2024-04-03 16:01:44 +05:30
committed by GitHub
39 changed files with 498 additions and 234 deletions

View File

@@ -453,7 +453,10 @@ frappe.ui.form.on("Journal Entry Account", {
}
},
cost_center: function (frm, dt, dn) {
erpnext.journal_entry.set_account_details(frm, dt, dn);
// Don't reset for Gain/Loss type journals, as it will make Debit and Credit values '0'
if (frm.doc.voucher_type != "Exchange Gain Or Loss") {
erpnext.journal_entry.set_account_details(frm, dt, dn);
}
},
account: function (frm, dt, dn) {

View File

@@ -579,6 +579,7 @@
"fieldtype": "Select",
"hidden": 1,
"label": "Payment Order Status",
"no_copy": 1,
"options": "Initiated\nPayment Ordered",
"read_only": 1
},
@@ -821,4 +822,4 @@
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -671,7 +671,7 @@ class ReceivablePayableReport(object):
else:
future_amount_field = "future_amount_in_base_currency"
if row.remaining_balance > 0 and future.get(future_amount_field):
if row.remaining_balance != 0 and future.get(future_amount_field):
if future.get(future_amount_field) > row.outstanding:
row.future_amount = row.outstanding
future[future_amount_field] = future.get(future_amount_field) - row.outstanding

View File

@@ -469,11 +469,30 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
)
def test_future_payments(self):
sr = self.create_sales_invoice(do_not_submit=True)
sr.is_return = 1
sr.items[0].qty = -1
sr.items[0].rate = 10
sr.calculate_taxes_and_totals()
sr.submit()
si = self.create_sales_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.append(
"references",
{
"reference_doctype": sr.doctype,
"reference_name": sr.name,
"due_date": sr.due_date,
"total_amount": sr.grand_total,
"outstanding_amount": sr.outstanding_amount,
"allocated_amount": sr.outstanding_amount,
},
)
pe.posting_date = add_days(today(), 1)
pe.paid_amount = 90.0
pe.references[0].allocated_amount = 90.0
pe.paid_amount = 80
pe.references[0].allocated_amount = 90.0 # pe.paid_amount + sr.grand_total
pe.save().submit()
filters = {
"company": self.company,
@@ -485,16 +504,21 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"show_future_payments": True,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
self.assertEqual(len(report), 2)
expected_data = [100.0, 100.0, 10.0, 90.0]
expected_data = {sr.name: [10.0, -10.0, 0.0, -10], si.name: [100.0, 100.0, 10.0, 90.0]}
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
rows = report[:2]
for row in rows:
self.assertEqual(
expected_data[row.voucher_no],
[row.invoiced or row.paid, row.outstanding, row.remaining_balance, row.future_amount],
)
pe.cancel()
sr.load_from_db() # Outstanding amount is updated so a updated timestamp is needed.
sr.cancel()
# full payment in future date
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)

View File

@@ -462,7 +462,7 @@
},
{
"fieldname": "other_charges_calculation",
"fieldtype": "Markdown Editor",
"fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -928,7 +928,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-03-20 16:03:59.069145",
"modified": "2024-03-28 10:20:30.231915",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",

View File

@@ -71,7 +71,7 @@ class SupplierQuotation(BuyingController):
naming_series: DF.Literal["PUR-SQTN-.YYYY.-"]
net_total: DF.Currency
opportunity: DF.Link | None
other_charges_calculation: DF.MarkdownEditor | None
other_charges_calculation: DF.TextEditor | None
plc_conversion_rate: DF.Float
price_list_currency: DF.Link | None
pricing_rules: DF.Table[PricingRuleDetail]

View File

@@ -750,12 +750,12 @@ def get_serial_and_batch_bundle(child, parent):
"item_code": child.item_code,
"warehouse": child.warehouse,
"voucher_type": parent.doctype,
"voucher_no": parent.name,
"voucher_no": parent.name if parent.docstatus < 2 else None,
"voucher_detail_no": child.name,
"posting_date": parent.posting_date,
"posting_time": parent.posting_time,
"qty": child.qty,
"type_of_transaction": "Outward" if child.qty > 0 else "Inward",
"type_of_transaction": "Outward" if child.qty > 0 and parent.docstatus < 2 else "Inward",
"company": parent.company,
"do_not_submit": "True",
}

View File

@@ -913,7 +913,7 @@ class StockController(AccountsController):
self.validate_multi_currency()
self.validate_packed_items()
if self.get("is_internal_supplier"):
if self.get("is_internal_supplier") and self.docstatus == 1:
self.validate_internal_transfer_qty()
else:
self.validate_internal_transfer_warehouse()

View File

@@ -238,7 +238,7 @@
"fieldname": "rm_cost_as_per",
"fieldtype": "Select",
"label": "Rate Of Materials Based On",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List\nManual"
"options": "Valuation Rate\nLast Purchase Rate\nPrice List"
},
{
"allow_on_submit": 1,
@@ -637,7 +637,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2023-12-26 19:34:08.159312",
"modified": "2024-04-02 16:22:47.518411",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",
@@ -676,4 +676,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -150,7 +150,7 @@ class BOM(WebsiteGenerator):
quality_inspection_template: DF.Link | None
quantity: DF.Float
raw_material_cost: DF.Currency
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List", "Manual"]
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
route: DF.SmallText | None
routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem]
@@ -742,6 +742,7 @@ class BOM(WebsiteGenerator):
def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0
@@ -750,7 +751,7 @@ class BOM(WebsiteGenerator):
continue
old_rate = d.rate
if self.rm_cost_as_per != "Manual":
if not self.bom_creator:
d.rate = self.get_rm_rate(
{
"company": self.company,
@@ -1022,8 +1023,6 @@ def get_bom_item_rate(args, bom_doc):
item_doc = frappe.get_cached_doc("Item", args.get("item_code"))
price_list_data = get_price_list_rate(bom_args, item_doc)
rate = price_list_data.price_list_rate
elif bom_doc.rm_cost_as_per == "Manual":
return
return flt(rate)

View File

@@ -66,7 +66,7 @@
"fieldname": "rm_cost_as_per",
"fieldtype": "Select",
"label": "Rate Of Materials Based On",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List\nManual",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List",
"reqd": 1
},
{
@@ -288,7 +288,7 @@
"link_fieldname": "bom_creator"
}
],
"modified": "2023-08-07 15:45:06.176313",
"modified": "2024-04-02 16:30:59.779190",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Creator",
@@ -327,4 +327,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -59,7 +59,7 @@ class BOMCreator(Document):
qty: DF.Float
raw_material_cost: DF.Currency
remarks: DF.TextEditor | None
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List", "Manual"]
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
set_rate_based_on_warehouse: DF.Check
status: DF.Literal["Draft", "Submitted", "In Progress", "Completed", "Failed", "Cancelled"]
uom: DF.Link | None
@@ -143,9 +143,6 @@ class BOMCreator(Document):
self.submit()
def set_rate_for_items(self):
if self.rm_cost_as_per == "Manual":
return
amount = self.get_raw_material_cost()
self.raw_material_cost = amount
@@ -240,6 +237,9 @@ class BOMCreator(Document):
(row.item_code, row.name), frappe._dict({"items": [], "bom_no": "", "fg_item_data": row})
)
if not row.fg_reference_id and production_item_wise_rm.get((row.fg_item, row.fg_reference_id)):
frappe.throw(_("Please set Parent Row No for item {0}").format(row.fg_item))
production_item_wise_rm[(row.fg_item, row.fg_reference_id)]["items"].append(row)
reverse_tree = OrderedDict(reversed(list(production_item_wise_rm.items())))
@@ -283,7 +283,6 @@ class BOMCreator(Document):
"allow_alternative_item": 1,
"bom_creator": self.name,
"bom_creator_item": bom_creator_item,
"rm_cost_as_per": "Manual",
}
)

View File

@@ -145,6 +145,7 @@ class Project(Document):
is_group=task_details.is_group,
color=task_details.color,
template_task=task_details.name,
priority=task_details.priority,
)
).insert()

View File

@@ -23,7 +23,11 @@ class TestProject(FrappeTestCase):
task1 = task_exists("Test Template Task with No Parent and Dependency")
if not task1:
task1 = create_task(
subject="Test Template Task with No Parent and Dependency", is_template=1, begin=5, duration=3
subject="Test Template Task with No Parent and Dependency",
is_template=1,
begin=5,
duration=3,
priority="High",
)
template = make_project_template(
@@ -32,11 +36,12 @@ class TestProject(FrappeTestCase):
project = get_project(project_name, template)
tasks = frappe.get_all(
"Task",
["subject", "exp_end_date", "depends_on_tasks"],
["subject", "exp_end_date", "depends_on_tasks", "priority"],
dict(project=project.name),
order_by="creation asc",
)
self.assertEqual(tasks[0].priority, "High")
self.assertEqual(tasks[0].subject, "Test Template Task with No Parent and Dependency")
self.assertEqual(getdate(tasks[0].exp_end_date), calculate_end_date(project, 5, 3))
self.assertEqual(len(tasks), 1)

View File

@@ -122,6 +122,7 @@ def create_task(
begin=0,
duration=0,
save=True,
priority=None,
):
if not frappe.db.exists("Task", subject):
task = frappe.new_doc("Task")
@@ -139,6 +140,7 @@ def create_task(
task.duration = duration
task.is_group = is_group
task.parent_task = parent_task
task.priority = priority
if save:
task.save()
else:

View File

@@ -342,7 +342,6 @@ erpnext.buying = {
add_serial_batch_bundle(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;
let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
.then((r) => {
@@ -352,30 +351,28 @@ erpnext.buying = {
item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward";
item.is_rejected = false;
frappe.require(path, function() {
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
let update_values = {
"serial_and_batch_bundle": r.name,
"use_serial_batch_fields": 0,
"qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item))
}
if (r.warehouse) {
update_values["warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
let update_values = {
"serial_and_batch_bundle": r.name,
"use_serial_batch_fields": 0,
"qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item))
}
if (r.warehouse) {
update_values["warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
}
);
});
}
);
}
});
}
@@ -383,40 +380,37 @@ erpnext.buying = {
add_serial_batch_for_rejected_qty(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;
let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
.then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward";
item.type_of_transaction = item.rejected_qty > 0 ? "Inward" : "Outward";
item.is_rejected = true;
frappe.require(path, function() {
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
let update_values = {
"serial_and_batch_bundle": r.name,
"use_serial_batch_fields": 0,
"rejected_qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item))
}
if (r.warehouse) {
update_values["rejected_warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
let update_values = {
"serial_and_batch_bundle": r.name,
"use_serial_batch_fields": 0,
"rejected_qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item))
}
if (r.warehouse) {
update_values["rejected_warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
}
);
});
}
);
}
});
}

View File

@@ -415,7 +415,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let row = locals[cdt][cdn];
if (row.barcode) {
erpnext.stock.utils.set_item_details_using_barcode(this.frm, row, (r) => {
debugger
frappe.model.set_value(cdt, cdn, {
"item_code": r.message.item_code,
"qty": 1,
@@ -2499,27 +2498,25 @@ erpnext.show_serial_batch_selector = function (frm, item_row, callback, on_close
}
}
frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function() {
if (["Sales Invoice", "Delivery Note"].includes(frm.doc.doctype)) {
item_row.type_of_transaction = frm.doc.is_return ? "Inward" : "Outward";
} else {
item_row.type_of_transaction = frm.doc.is_return ? "Outward" : "Inward";
}
if (["Sales Invoice", "Delivery Note"].includes(frm.doc.doctype)) {
item_row.type_of_transaction = frm.doc.is_return ? "Inward" : "Outward";
} else {
item_row.type_of_transaction = frm.doc.is_return ? "Outward" : "Inward";
}
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
let update_values = {
"serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
}
if (r.warehouse) {
update_values[warehouse_field] = r.warehouse;
}
frappe.model.set_value(item_row.doctype, item_row.name, update_values);
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
let update_values = {
"serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
}
});
if (r.warehouse) {
update_values[warehouse_field] = r.warehouse;
}
frappe.model.set_value(item_row.doctype, item_row.name, update_values);
}
});
}

View File

@@ -4,6 +4,7 @@ import "./queries";
import "./sms_manager";
import "./utils/party";
import "./controllers/stock_controller";
import "./utils/serial_no_batch_selector";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";
import "./plant_floor_visual/visual_plant";

View File

@@ -430,25 +430,23 @@ $.extend(erpnext.utils, {
item_row.has_batch_no = r.message.has_batch_no;
item_row.has_serial_no = r.message.has_serial_no;
frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function () {
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
let update_values = {
serial_and_batch_bundle: r.name,
qty: Math.abs(r.total_qty),
};
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
let update_values = {
serial_and_batch_bundle: r.name,
qty: Math.abs(r.total_qty),
};
if (!warehouse_field) {
warehouse_field = "warehouse";
}
if (r.warehouse) {
update_values[warehouse_field] = r.warehouse;
}
frappe.model.set_value(item_row.doctype, item_row.name, update_values);
if (!warehouse_field) {
warehouse_field = "warehouse";
}
});
if (r.warehouse) {
update_values[warehouse_field] = r.warehouse;
}
frappe.model.set_value(item_row.doctype, item_row.name, update_values);
}
});
});
},

View File

@@ -350,7 +350,6 @@ erpnext.sales_common = {
pick_serial_and_batch(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;
let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
@@ -364,26 +363,24 @@ erpnext.sales_common = {
item.title = __("Select Serial and Batch");
}
frappe.require(path, function () {
new erpnext.SerialBatchPackageSelector(me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty:
qty /
flt(
item.conversion_factor || 1,
precision("conversion_factor", item)
),
});
new erpnext.SerialBatchPackageSelector(me.frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (doc.is_return) {
qty = qty * -1;
}
});
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty:
qty /
flt(
item.conversion_factor || 1,
precision("conversion_factor", item)
),
});
}
});
}
});

View File

@@ -394,19 +394,17 @@ erpnext.PointOfSale.ItemDetails = class {
bind_auto_serial_fetch_event() {
this.$form_container.on("click", ".auto-fetch-btn", () => {
frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", () => {
let frm = this.events.get_frm();
let item_row = this.item_row;
item_row.type_of_transaction = "Outward";
let frm = this.events.get_frm();
let item_row = this.item_row;
item_row.type_of_transaction = "Outward";
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
frappe.model.set_value(item_row.doctype, item_row.name, {
serial_and_batch_bundle: r.name,
qty: Math.abs(r.total_qty),
});
}
});
new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
frappe.model.set_value(item_row.doctype, item_row.name, {
serial_and_batch_bundle: r.name,
qty: Math.abs(r.total_qty),
});
}
});
});
}

View File

@@ -1287,6 +1287,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
for tax in get_taxes_and_charges(master_doctype, target.get("taxes_and_charges")):
target.append("taxes", tax)
if not target.get("items"):
frappe.throw(_("All items have already been received"))
def update_details(source_doc, target_doc, source_parent):
target_doc.inter_company_invoice_reference = source_doc.name
if target_doc.doctype == "Purchase Receipt":
@@ -1342,6 +1345,10 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
shipping_address_name=target_doc.shipping_address_name,
)
def update_item(source, target, source_parent):
if source_parent.doctype == "Delivery Note" and source.received_qty:
target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty)
doclist = get_mapped_doc(
doctype,
source_name,
@@ -1363,6 +1370,8 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"Material_request_item": "material_request_item",
},
"field_no_map": ["warehouse"],
"condition": lambda item: item.received_qty < item.qty + item.returned_qty,
"postprocess": update_item,
},
},
target_doc,

View File

@@ -32,7 +32,7 @@ test_ignore = ["BOM"]
test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"]
def make_item(item_code=None, properties=None, uoms=None):
def make_item(item_code=None, properties=None, uoms=None, barcode=None):
if not item_code:
item_code = frappe.generate_hash(length=16)
@@ -61,6 +61,14 @@ def make_item(item_code=None, properties=None, uoms=None):
for uom in uoms:
item.append("uoms", uom)
if barcode:
item.append(
"barcodes",
{
"barcode": barcode,
},
)
item.insert()
return item

View File

@@ -355,19 +355,15 @@ frappe.ui.form.on("Pick List Item", {
item.title = __("Select Serial and Batch");
}
frappe.require(path, function () {
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty:
qty /
flt(item.conversion_factor || 1, precision("conversion_factor", item)),
});
}
});
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty: qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)),
});
}
});
}
});

View File

@@ -18,6 +18,7 @@
"parent_warehouse",
"consider_rejected_warehouses",
"get_item_locations",
"pick_manually",
"section_break_6",
"scan_barcode",
"column_break_13",
@@ -192,11 +193,18 @@
"fieldname": "consider_rejected_warehouses",
"fieldtype": "Check",
"label": "Consider Rejected Warehouses"
},
{
"default": "0",
"description": "If enabled then system won't override the picked qty / batches / serial numbers.",
"fieldname": "pick_manually",
"fieldtype": "Check",
"label": "Pick Manually"
}
],
"is_submittable": 1,
"links": [],
"modified": "2024-02-02 16:17:44.877426",
"modified": "2024-03-27 22:49:16.954637",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -42,6 +42,7 @@ class PickList(Document):
amended_from: DF.Link | None
company: DF.Link
consider_rejected_warehouses: DF.Check
customer: DF.Link | None
customer_name: DF.Data | None
for_qty: DF.Float
@@ -50,6 +51,7 @@ class PickList(Document):
material_request: DF.Link | None
naming_series: DF.Literal["STO-PICK-.YYYY.-"]
parent_warehouse: DF.Link | None
pick_manually: DF.Check
prompt_qty: DF.Check
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
scan_barcode: DF.Data | None
@@ -71,7 +73,8 @@ class PickList(Document):
def before_save(self):
self.update_status()
self.set_item_locations()
if not self.pick_manually:
self.set_item_locations()
if self.get("locations"):
self.validate_sales_order_percentage()

View File

@@ -1335,18 +1335,16 @@ erpnext.stock.select_batch_and_serial_no = (frm, item) => {
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.s_warehouse ? "Outward" : "Inward";
frappe.require(path, function () {
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty:
Math.abs(r.total_qty) /
flt(item.conversion_factor || 1, precision("conversion_factor", item)),
});
}
});
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
frappe.model.set_value(item.doctype, item.name, {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty:
Math.abs(r.total_qty) /
flt(item.conversion_factor || 1, precision("conversion_factor", item)),
});
}
});
}
});

View File

@@ -31,6 +31,7 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
OpeningEntryAccountError,
)
from erpnext.stock.get_item_details import (
get_barcode_data,
get_bin_details,
get_conversion_factor,
get_default_cost_center,
@@ -428,7 +429,14 @@ class StockEntry(StockController):
for field in reset_fields:
item.set(field, item_details.get(field))
update_fields = ("uom", "description", "expense_account", "cost_center", "conversion_factor")
update_fields = (
"uom",
"description",
"expense_account",
"cost_center",
"conversion_factor",
"barcode",
)
for field in update_fields:
if not item.get(field):
@@ -1609,6 +1617,10 @@ class StockEntry(StockController):
if subcontract_items and len(subcontract_items) == 1:
ret["subcontracted_item"] = subcontract_items[0].main_item_code
barcode_data = get_barcode_data(item_code=item.name)
if barcode_data and len(barcode_data.get(item.name)) == 1:
ret["barcode"] = barcode_data.get(item.name)[0]
return ret
@frappe.whitelist()

View File

@@ -98,6 +98,12 @@ class TestStockEntry(FrappeTestCase):
self._test_auto_material_request("_Test Item")
self._test_auto_material_request("_Test Item", material_request_type="Transfer")
def test_barcode_item_stock_entry(self):
item_code = make_item("_Test Item Stock Entry For Barcode", barcode="BDD-1234567890")
se = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100)
self.assertEqual(se.items[0].barcode, "BDD-1234567890")
def test_auto_material_request_for_variant(self):
fields = [{"field_name": "reorder_levels"}]
set_item_variant_settings(fields)

View File

@@ -81,6 +81,18 @@ frappe.ui.form.on("Stock Reconciliation", {
if (frm.doc.company) {
frm.trigger("toggle_display_account_head");
}
frm.events.set_fields_onload_for_line_item(frm);
},
set_fields_onload_for_line_item(frm) {
if (frm.is_new() && frm.doc?.items && cint(frappe.user_defaults?.use_serial_batch_fields) === 1) {
frm.doc.items.forEach((item) => {
if (!item.serial_and_batch_bundle) {
frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1);
}
});
}
},
scan_barcode: function (frm) {
@@ -155,6 +167,9 @@ frappe.ui.form.on("Stock Reconciliation", {
item.qty = item.qty || 0;
item.valuation_rate = item.valuation_rate || 0;
item.use_serial_batch_fields = cint(
frappe.user_defaults?.use_serial_batch_fields
);
});
frm.refresh_field("items");
},
@@ -298,6 +313,10 @@ frappe.ui.form.on("Stock Reconciliation Item", {
if (!item.warehouse && frm.doc.set_warehouse) {
frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.set_warehouse);
}
if (item.docstatus === 0 && cint(frappe.user_defaults?.use_serial_batch_fields) === 1) {
frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1);
}
},
add_serial_batch_bundle(frm, cdt, cdn) {

View File

@@ -49,6 +49,7 @@ frappe.ui.form.on("Warehouse", {
frm.add_custom_button(__("Stock Balance"), function () {
frappe.set_route("query-report", "Stock Balance", {
warehouse: frm.doc.name,
company: frm.doc.company,
});
});

View File

@@ -499,12 +499,21 @@ def update_barcode_value(out):
out["barcode"] = barcode_data.get(out.item_code)[0]
def get_barcode_data(items_list):
def get_barcode_data(items_list=None, item_code=None):
# get item-wise batch no data
# example: {'LED-GRE': [Batch001, Batch002]}
# where LED-GRE is item code, SN0001 is serial no and Pune is warehouse
itemwise_barcode = {}
if not items_list and item_code:
_dict_item_code = frappe._dict(
{
"item_code": item_code,
}
)
items_list = [frappe._dict(_dict_item_code)]
for item in items_list:
barcodes = frappe.db.get_all(
"Item Barcode", filters={"parent": item.item_code}, fields="barcode"

View File

@@ -2,8 +2,8 @@
# License: GNU General Public License v3. See license.txt
from collections.abc import Iterator
from operator import itemgetter
from typing import Dict, List, Tuple, Union
import frappe
from frappe import _
@@ -14,7 +14,7 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict
def execute(filters: Filters = None) -> Tuple:
def execute(filters: Filters = None) -> tuple:
to_date = filters["to_date"]
columns = get_columns(filters)
@@ -26,14 +26,14 @@ def execute(filters: Filters = None) -> Tuple:
return columns, data, None, chart_data
def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> List[Dict]:
def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[dict]:
"Returns ordered, formatted data with ranges."
_func = itemgetter(1)
data = []
precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True))
for item, item_dict in item_details.items():
for _item, item_dict in item_details.items():
if not flt(item_dict.get("total_qty"), precision):
continue
@@ -74,12 +74,12 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
return data
def get_average_age(fifo_queue: List, to_date: str) -> float:
def get_average_age(fifo_queue: list, to_date: str) -> float:
batch_age = age_qty = total_qty = 0.0
for batch in fifo_queue:
batch_age = date_diff(to_date, batch[1])
if isinstance(batch[0], (int, float)):
if isinstance(batch[0], int | float):
age_qty += batch_age * batch[0]
total_qty += batch[0]
else:
@@ -89,8 +89,7 @@ def get_average_age(fifo_queue: List, to_date: str) -> float:
return flt(age_qty / total_qty, 2) if total_qty else 0.0
def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple:
def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> tuple:
precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True))
range1 = range2 = range3 = above_range3 = 0.0
@@ -111,7 +110,7 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
return range1, range2, range3, above_range3
def get_columns(filters: Filters) -> List[Dict]:
def get_columns(filters: Filters) -> list[dict]:
range_columns = []
setup_ageing_columns(filters, range_columns)
columns = [
@@ -169,7 +168,7 @@ def get_columns(filters: Filters) -> List[Dict]:
return columns
def get_chart_data(data: List, filters: Filters) -> Dict:
def get_chart_data(data: list, filters: Filters) -> dict:
if not data:
return []
@@ -193,7 +192,7 @@ def get_chart_data(data: List, filters: Filters) -> Dict:
}
def setup_ageing_columns(filters: Filters, range_columns: List):
def setup_ageing_columns(filters: Filters, range_columns: list):
ranges = [
f"0 - {filters['range1']}",
f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}",
@@ -205,23 +204,21 @@ def setup_ageing_columns(filters: Filters, range_columns: List):
add_column(range_columns, label=_("Age ({0})").format(label), fieldname=fieldname)
def add_column(
range_columns: List, label: str, fieldname: str, fieldtype: str = "Float", width: int = 140
):
def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = "Float", width: int = 140):
range_columns.append(dict(label=label, fieldname=fieldname, fieldtype=fieldtype, width=width))
class FIFOSlots:
"Returns FIFO computed slots of inwarded stock as per date."
def __init__(self, filters: Dict = None, sle: List = None):
def __init__(self, filters: dict | None = None, sle: list | None = None):
self.item_details = {}
self.transferred_item_details = {}
self.serial_no_batch_purchase_details = {}
self.filters = filters
self.sle = sle
def generate(self) -> Dict:
def generate(self) -> dict:
"""
Returns dict of the foll.g structure:
Key = Item A / (Item A, Warehouse A)
@@ -231,25 +228,45 @@ class FIFOSlots:
consumed/updated and maintained via FIFO. **
}
"""
if self.sle is None:
self.sle = self.__get_stock_ledger_entries()
for d in self.sle:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_serial_nos_from_bundle,
)
if d.voucher_type == "Stock Reconciliation":
# get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
stock_ledger_entries = self.sle
serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
bundle_wise_serial_nos = frappe._dict({})
if stock_ledger_entries is None:
bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos()
if d.actual_qty > 0:
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
else:
self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos)
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self.__get_stock_ledger_entries()
self.__update_balances(d, key)
for d in stock_ledger_entries:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
# get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
if d.serial_and_batch_bundle and d.has_serial_no:
if bundle_wise_serial_nos:
serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or []
else:
serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or []
if d.actual_qty > 0:
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
else:
self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos)
self.__update_balances(d, key)
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
@@ -257,7 +274,7 @@ class FIFOSlots:
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
def __init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."
key = (row.name, row.warehouse)
@@ -269,9 +286,7 @@ class FIFOSlots:
return key, fifo_queue, transferred_item_key
def __compute_incoming_stock(
self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List
):
def __compute_incoming_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list):
"Update FIFO Queue on inward stock."
transfer_data = self.transferred_item_details.get(transfer_key)
@@ -297,9 +312,7 @@ class FIFOSlots:
self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
fifo_queue.append([serial_no, row.posting_date])
def __compute_outgoing_stock(
self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List
):
def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list):
"Update FIFO Queue on outward stock."
if serial_nos:
fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos]
@@ -325,7 +338,7 @@ class FIFOSlots:
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0
def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict):
"Add previously removed stock back to FIFO Queue."
transfer_qty_to_pop = flt(row.actual_qty)
@@ -352,7 +365,7 @@ class FIFOSlots:
add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
transfer_qty_to_pop = 0
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
def __update_balances(self, row: dict, key: tuple | str):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
if "total_qty" not in self.item_details[key]:
@@ -362,7 +375,7 @@ class FIFOSlots:
self.item_details[key]["has_serial_no"] = row.has_serial_no
def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict:
"Aggregate Item-Wh wise data into single Item entry."
item_aggregated_data = {}
for key, row in wh_wise_data.items():
@@ -370,7 +383,12 @@ class FIFOSlots:
if not item_aggregated_data.get(item):
item_aggregated_data.setdefault(
item,
{"details": frappe._dict(), "fifo_queue": [], "qty_after_transaction": 0.0, "total_qty": 0.0},
{
"details": frappe._dict(),
"fifo_queue": [],
"qty_after_transaction": 0.0,
"total_qty": 0.0,
},
)
item_row = item_aggregated_data.get(item)
item_row["details"].update(row["details"])
@@ -381,7 +399,7 @@ class FIFOSlots:
return item_aggregated_data
def __get_stock_ledger_entries(self) -> List[Dict]:
def __get_stock_ledger_entries(self) -> Iterator[dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query
@@ -403,6 +421,7 @@ class FIFOSlots:
sle.serial_no,
sle.batch_no,
sle.qty_after_transaction,
sle.serial_and_batch_bundle,
sle.warehouse,
)
.where(
@@ -418,7 +437,34 @@ class FIFOSlots:
sle_query = sle_query.orderby(sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty)
return sle_query.run(as_dict=True)
return sle_query.run(as_dict=True, as_iterator=True)
def __get_bundle_wise_serial_nos(self) -> dict:
bundle = frappe.qb.DocType("Serial and Batch Bundle")
entry = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(bundle)
.join(entry)
.on(bundle.name == entry.parent)
.select(bundle.name, entry.serial_no)
.where(
(bundle.docstatus == 1)
& (entry.serial_no.isnotnull())
& (bundle.company == self.filters.get("company"))
& (bundle.posting_date <= self.filters.get("to_date"))
)
)
for field in ["item_code", "warehouse"]:
if self.filters.get(field):
query = query.where(bundle[field] == self.filters.get(field))
bundle_wise_serial_nos = frappe._dict({})
for bundle_name, serial_no in query.run():
bundle_wise_serial_nos.setdefault(bundle_name, []).append(serial_no)
return bundle_wise_serial_nos
def __get_item_query(self) -> str:
item_table = frappe.qb.DocType("Item")

View File

@@ -295,6 +295,8 @@ class StockBalanceReport(object):
sle.stock_value,
sle.batch_no,
sle.serial_no,
sle.serial_and_batch_bundle,
sle.has_serial_no,
item_table.item_group,
item_table.stock_uom,
item_table.item_name,

View File

@@ -856,6 +856,11 @@ class SerialBatchCreation:
if not doc.get("entries"):
return frappe._dict({})
if (
doc.voucher_no and frappe.get_cached_value(doc.voucher_type, doc.voucher_no, "docstatus") == 2
):
doc.voucher_no = ""
doc.save()
self.validate_qty(doc)

View File

@@ -335,12 +335,115 @@ frappe.ui.form.on("Subcontracting Receipt Item", {
items_remove: (frm) => {
set_missing_values(frm);
},
add_serial_batch_bundle(frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward";
item.is_rejected = false;
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (frm.doc.is_return) {
qty = qty * -1;
}
let update_values = {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
qty: qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)),
};
if (r.warehouse) {
update_values["warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
}
});
}
});
},
add_serial_batch_for_rejected_qty(frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.rejected_qty > 0 ? "Inward" : "Outward";
item.is_rejected = true;
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (frm.doc.is_return) {
qty = qty * -1;
}
let update_values = {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
rejected_qty:
qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)),
};
if (r.warehouse) {
update_values["rejected_warehouse"] = r.warehouse;
}
frappe.model.set_value(item.doctype, item.name, update_values);
}
});
}
});
},
});
frappe.ui.form.on("Subcontracting Receipt Supplied Item", {
consumed_qty(frm) {
set_missing_values(frm);
},
add_serial_batch_bundle(frm, cdt, cdn) {
let item = locals[cdt][cdn];
item.item_code = item.rm_item_code;
item.qty = item.consumed_qty;
item.warehouse = frm.doc.supplier_warehouse;
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Outward" : "Inward";
item.is_rejected = false;
new erpnext.SerialBatchPackageSelector(frm, item, (r) => {
if (r) {
let qty = Math.abs(r.total_qty);
if (frm.doc.is_return) {
qty = qty * -1;
}
let update_values = {
serial_and_batch_bundle: r.name,
use_serial_batch_fields: 0,
consumed_qty:
qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)),
};
frappe.model.set_value(item.doctype, item.name, update_values);
}
});
}
});
},
});
let set_warehouse_in_children = (child_table, warehouse_field, warehouse) => {

View File

@@ -47,9 +47,11 @@
"schedule_date",
"reference_name",
"section_break_45",
"add_serial_batch_bundle",
"serial_and_batch_bundle",
"use_serial_batch_fields",
"col_break5",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
"section_break_jshh",
"serial_no",
@@ -563,12 +565,24 @@
{
"fieldname": "column_break_henr",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0",
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch Bundle"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0",
"fieldname": "add_serial_batch_for_rejected_qty",
"fieldtype": "Button",
"label": "Add Serial / Batch No (Rejected Qty)"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-07 11:43:38.954262",
"modified": "2024-03-29 15:42:43.425544",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",
@@ -579,4 +593,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -25,6 +25,7 @@
"consumed_qty",
"current_stock",
"secbreak_3",
"add_serial_batch_bundle",
"serial_and_batch_bundle",
"use_serial_batch_fields",
"col_break4",
@@ -224,12 +225,18 @@
{
"fieldname": "column_break_qibi",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0",
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch Bundle"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-02-04 16:32:17.534162",
"modified": "2024-03-30 10:26:27.237371",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Supplied Item",
@@ -240,4 +247,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -1,14 +1,14 @@
{
"accept_payment": 0,
"allow_comments": 1,
"allow_delete": 1,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 1,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"anonymous": 0,
"apply_document_permissions": 0,
"breadcrumbs": "[{\"label\":_(\"Issues\"), \"route\":\"issues\"}]",
"condition_json": "[]",
"creation": "2016-06-24 15:50:33.186483",
"doc_type": "Issue",
"docstatus": 0,
@@ -16,20 +16,19 @@
"idx": 0,
"introduction_text": "",
"is_standard": 1,
"list_columns": [],
"login_required": 1,
"max_attachment_size": 0,
"modified": "2020-05-19 13:01:10.729088",
"modified": "2024-03-27 16:16:03.621730",
"modified_by": "Administrator",
"module": "Support",
"name": "issues",
"owner": "Administrator",
"published": 1,
"route": "issues",
"route_to_success_link": 0,
"show_attachments": 0,
"show_in_grid": 0,
"show_list": 1,
"show_sidebar": 1,
"sidebar_items": [],
"success_message": "",
"success_url": "/issues",
"title": "Issue",