mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-24 12:36:37 +00:00
fix: allow specific methods to run
(cherry picked from commit 8db1eb0d27)
# Conflicts:
# erpnext/manufacturing/doctype/job_card/job_card.js
# erpnext/manufacturing/doctype/job_card/job_card.py
# erpnext/manufacturing/doctype/workstation/test_workstation.py
# erpnext/manufacturing/doctype/workstation/workstation.py
This commit is contained in:
committed by
Mergify
parent
2c4b89d1df
commit
412a3836bd
@@ -298,9 +298,169 @@ frappe.ui.form.on("Job Card", {
|
||||
prepare_timer_buttons: function (frm) {
|
||||
frm.trigger("make_dashboard");
|
||||
|
||||
<<<<<<< HEAD
|
||||
if (!frm.doc.started_time && !frm.doc.current_time) {
|
||||
frm.add_custom_button(__("Start Job"), () => {
|
||||
if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) {
|
||||
=======
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log",
|
||||
args: { args },
|
||||
freeze: true,
|
||||
callback() {
|
||||
frm.reload_doc();
|
||||
frm.trigger("make_dashboard");
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
update_sub_operation(frm, args) {
|
||||
if (frm.doc.sub_operations?.length) {
|
||||
const pending_sub_ops = frm.doc.sub_operations.filter((d) => d.status != "Complete");
|
||||
if (pending_sub_ops.length) {
|
||||
args["sub_operation"] = pending_sub_ops[0].sub_operation;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
make_dashboard(frm, has_items) {
|
||||
if (frm.doc.__islocal) return false;
|
||||
|
||||
frm.dashboard.refresh();
|
||||
|
||||
// Clear any previously running timer tick before re-rendering.
|
||||
if (frm._jcd_timer_interval) {
|
||||
clearInterval(frm._jcd_timer_interval);
|
||||
frm._jcd_timer_interval = null;
|
||||
}
|
||||
|
||||
const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper);
|
||||
wrapper.empty();
|
||||
|
||||
if (frm.doc.docstatus !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc } = frm;
|
||||
const { time_logs, status } = doc;
|
||||
|
||||
// ── Determine which action buttons to show ────────────────────────
|
||||
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
|
||||
const materials_ready =
|
||||
doc.skip_material_transfer ||
|
||||
doc.transferred_qty >= doc.for_quantity + doc.process_loss_qty ||
|
||||
!doc.finished_good ||
|
||||
!has_items?.length;
|
||||
|
||||
let last_row = {};
|
||||
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;
|
||||
if (has_sub_ops_or_pending_qty && time_logs?.length) {
|
||||
last_row = get_last_row(time_logs);
|
||||
}
|
||||
|
||||
const no_time_logs_yet = !time_logs?.length;
|
||||
const pending_qty_cycle_done = flt(doc.pending_qty) > 0.0 && last_row?.to_time;
|
||||
const sub_operation_cycle_done = doc.sub_operations?.length && last_row?.to_time;
|
||||
const should_show_start =
|
||||
(no_time_logs_yet || pending_qty_cycle_done || sub_operation_cycle_done) && !doc.is_paused;
|
||||
|
||||
const last_log_complete = time_logs?.length && time_logs[time_logs.length - 1].to_time;
|
||||
const is_on_hold = status === "On Hold";
|
||||
const is_actively_running = !!(
|
||||
time_logs?.length &&
|
||||
!last_log_complete &&
|
||||
!is_on_hold &&
|
||||
!doc.is_paused
|
||||
);
|
||||
|
||||
let show_start = false,
|
||||
show_pause = false,
|
||||
show_resume = false,
|
||||
show_complete = false,
|
||||
is_timer_running = false;
|
||||
|
||||
if (has_remaining_qty && materials_ready) {
|
||||
const manufactured_qty = doc.manufactured_qty || doc.total_completed_qty;
|
||||
const qty_yet_to_manufacture = doc.for_quantity - (manufactured_qty + doc.process_loss_qty);
|
||||
|
||||
if (should_show_start) {
|
||||
show_start = true;
|
||||
} else if (doc.is_paused) {
|
||||
show_resume = true;
|
||||
} else if (qty_yet_to_manufacture > 0) {
|
||||
show_pause = true;
|
||||
show_complete = true;
|
||||
is_timer_running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timer color reflects job state ────────────────────────────────
|
||||
const [timer_color, timer_bg, timer_border] = [
|
||||
"var(--gray-600,#6b7280)",
|
||||
"var(--gray-100,#f3f4f6)",
|
||||
"var(--gray-300,#d1d5db)",
|
||||
];
|
||||
|
||||
// ── Action button HTML ────────────────────────────────────────────
|
||||
const btn = (cls, icon_path, label, icon_color) => `
|
||||
<button class="btn btn-sm ${cls}" style="display:inline-flex;align-items:center;gap:5px;font-weight:600;padding:6px 14px;">
|
||||
${frappe.utils.icon(icon_path, "sm", "", "", "", "", icon_color)}
|
||||
${label}
|
||||
</button>`;
|
||||
|
||||
const icons = {
|
||||
play: { d: '<polygon points="5 3 19 12 5 21 5 3"/>', fill: "currentColor", stroke: "none" },
|
||||
pause: {
|
||||
d: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>',
|
||||
fill: "currentColor",
|
||||
stroke: "none",
|
||||
},
|
||||
check: { d: '<polyline points="20 6 9 17 4 12"/>', sw: 3 },
|
||||
};
|
||||
|
||||
const buttons_html = [
|
||||
show_start && btn("btn-primary jcd-btn-start", "play", __("Start Job")),
|
||||
show_resume && btn("btn-primary jcd-btn-resume", "play", __("Resume Job")),
|
||||
show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")),
|
||||
show_complete && btn("btn-primary jcd-btn-complete", "check", __("Complete Job"), "white"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
// ── Render widget ─────────────────────────────────────────────────
|
||||
wrapper.append(`
|
||||
<div class="job-card-dashboard-widget"
|
||||
style="border:1px solid var(--border-color);border-radius:var(--border-radius-lg,8px);
|
||||
background:var(--card-bg,#fff);padding:16px 20px;margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);font-weight:600;
|
||||
text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px;">
|
||||
${__("Elapsed Time")}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)}
|
||||
<span class="jcd-stopwatch"
|
||||
style="font-family:var(--monospace-font,'Courier New',monospace);
|
||||
font-size:28px;font-weight:700;letter-spacing:2px;color:${timer_color};">
|
||||
00:00:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
${buttons_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// ── Wire up button click handlers ─────────────────────────────────
|
||||
if (show_start) {
|
||||
wrapper.find(".jcd-btn-start").on("click", () => {
|
||||
const from_time = frappe.datetime.now_datetime();
|
||||
const has_no_employee = !frm.doc.employee || !frm.doc.employee.length;
|
||||
|
||||
if (has_no_employee) {
|
||||
>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)
|
||||
frappe.prompt(
|
||||
{
|
||||
fieldtype: "Table MultiSelect",
|
||||
|
||||
@@ -1023,6 +1023,37 @@ class JobCard(Document):
|
||||
OperationMismatchError,
|
||||
)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
@frappe.whitelist()
|
||||
def pause_job(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
self.db_set("is_paused", 1)
|
||||
self.add_time_logs(to_time=kwargs.end_time, completed_qty=0.0, employees=self.employee)
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_job(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
self.db_set("is_paused", 0)
|
||||
self.add_time_logs(
|
||||
from_time=kwargs.start_time,
|
||||
employees=self.employee,
|
||||
completed_qty=0.0,
|
||||
)
|
||||
|
||||
>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)
|
||||
def validate_sequence_id(self):
|
||||
if self.is_corrective_job_card:
|
||||
return
|
||||
@@ -1088,6 +1119,231 @@ class JobCard(Document):
|
||||
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", status)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def add_time_logs(self, **kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
if not kwargs.employees and kwargs.to_time:
|
||||
for row in self.time_logs:
|
||||
if not row.to_time and row.from_time:
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
|
||||
if kwargs.completed_qty:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
else:
|
||||
self.add_time_logs_for_employess(kwargs)
|
||||
|
||||
self.validate_time_logs(save=True)
|
||||
self.save()
|
||||
|
||||
def add_time_logs_for_employess(self, kwargs):
|
||||
update_status = False
|
||||
|
||||
for employee in kwargs.employees:
|
||||
kwargs.employee = employee.get("employee")
|
||||
if kwargs.from_time and not kwargs.to_time:
|
||||
self.add_new_time_log_for_employee(kwargs)
|
||||
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
|
||||
self.update_completed_qty_for_employee(kwargs)
|
||||
update_status = True
|
||||
else:
|
||||
self.close_time_log_for_employee(kwargs)
|
||||
update_status = True
|
||||
|
||||
self.set_status(update_status=update_status)
|
||||
|
||||
def add_new_time_log_for_employee(self, kwargs):
|
||||
if kwargs.qty:
|
||||
kwargs.completed_qty = kwargs.qty
|
||||
|
||||
row = self.append("time_logs", kwargs)
|
||||
row.db_update()
|
||||
self.db_set("status", "Work In Progress")
|
||||
|
||||
def update_completed_qty_for_employee(self, kwargs):
|
||||
for row in self.time_logs:
|
||||
if row.employee != kwargs.employee:
|
||||
continue
|
||||
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
|
||||
def close_time_log_for_employee(self, kwargs):
|
||||
for row in self.time_logs:
|
||||
if row.to_time or row.employee != kwargs.employee:
|
||||
continue
|
||||
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
if kwargs.get("sub_operation"):
|
||||
row.operation = kwargs.get("sub_operation")
|
||||
|
||||
if kwargs.employees[-1].get("employee") == row.employee:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
|
||||
row.db_update()
|
||||
|
||||
def update_workstation_status(self):
|
||||
status_map = {
|
||||
"Open": "Off",
|
||||
"Work In Progress": "Production",
|
||||
"Completed": "Off",
|
||||
"On Hold": "Idle",
|
||||
}
|
||||
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
fields=["name", "status"],
|
||||
filters={"workstation": self.workstation, "docstatus": 0, "status": ("!=", "Completed")},
|
||||
order_by="status desc",
|
||||
)
|
||||
|
||||
if not job_cards:
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", "Off")
|
||||
|
||||
for row in job_cards:
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", status_map.get(row.status))
|
||||
return
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_timer(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
if isinstance(kwargs.employees, str):
|
||||
kwargs.employees = [{"employee": kwargs.employees}]
|
||||
|
||||
if kwargs.start_time:
|
||||
self.add_time_logs(from_time=kwargs.start_time, employees=kwargs.employees)
|
||||
|
||||
@frappe.whitelist()
|
||||
def complete_job_card(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
self.validate_complete_job_card_qty(kwargs)
|
||||
|
||||
self.pending_qty = flt(kwargs.pending_qty)
|
||||
self.process_loss_qty = flt(kwargs.process_loss_qty)
|
||||
|
||||
self.add_completion_time_logs(kwargs)
|
||||
|
||||
if kwargs.auto_submit:
|
||||
self.auto_submit_job_card(kwargs.auto_submit)
|
||||
|
||||
def validate_docstatus(self):
|
||||
if self.docstatus == 2:
|
||||
frappe.throw(_("Cancelled Job Card cannot be processed."))
|
||||
|
||||
if self.docstatus == 1:
|
||||
frappe.throw(_("Submitted Job Card cannot be processed."))
|
||||
|
||||
def validate_complete_job_card_qty(self, kwargs):
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
|
||||
frappe.throw(_("Pending quantity cannot be negative."))
|
||||
|
||||
if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0:
|
||||
frappe.throw(_("Process loss quantity cannot be negative."))
|
||||
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
|
||||
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
|
||||
|
||||
def add_completion_time_logs(self, kwargs):
|
||||
if kwargs.end_time:
|
||||
self.add_time_logs(
|
||||
to_time=kwargs.end_time,
|
||||
completed_qty=kwargs.qty,
|
||||
employees=self.employee,
|
||||
sub_operation=kwargs.get("sub_operation"),
|
||||
)
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.update_work_order()
|
||||
else:
|
||||
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
|
||||
self.save()
|
||||
|
||||
def auto_submit_job_card(self, auto_submit):
|
||||
self.submit()
|
||||
|
||||
if not self.finished_good:
|
||||
return
|
||||
|
||||
self.make_stock_entry_for_semi_fg_item(auto_submit)
|
||||
frappe.msgprint(_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)))
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
frappe.has_permission("Stock Entry", "create", throw=True)
|
||||
|
||||
ste = self.build_manufacture_stock_entry()
|
||||
self.populate_manufacture_stock_entry(ste)
|
||||
|
||||
if auto_submit:
|
||||
ste.stock_entry.submit()
|
||||
else:
|
||||
ste.stock_entry.save()
|
||||
|
||||
frappe.msgprint(
|
||||
_("Stock Entry {0} has created").format(get_link_to_form("Stock Entry", ste.stock_entry.name))
|
||||
)
|
||||
|
||||
return ste.stock_entry.as_dict()
|
||||
|
||||
def get_consumed_process_loss(self):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
def build_manufacture_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
|
||||
|
||||
return ManufactureEntry(
|
||||
{
|
||||
"for_quantity": self.for_quantity - self.manufactured_qty,
|
||||
"process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
|
||||
"job_card": self.name,
|
||||
"skip_material_transfer": self.skip_material_transfer,
|
||||
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Manufacture",
|
||||
"production_item": self.finished_good,
|
||||
"company": self.company,
|
||||
"wip_warehouse": self.wip_warehouse,
|
||||
"fg_warehouse": self.target_warehouse,
|
||||
"bom_no": self.semi_fg_bom,
|
||||
"project": frappe.db.get_value("Work Order", self.work_order, "project"),
|
||||
}
|
||||
)
|
||||
|
||||
def populate_manufacture_stock_entry(self, ste):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
|
||||
|
||||
ste.make_stock_entry()
|
||||
ste.stock_entry.flags.ignore_mandatory = True
|
||||
wo_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
add_additional_cost(ste.stock_entry, wo_doc, self)
|
||||
ManufactureStockEntry(ste.stock_entry).add_secondary_items_from_job_card()
|
||||
for row in ste.stock_entry.items:
|
||||
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
|
||||
row.t_warehouse = self.target_warehouse
|
||||
|
||||
>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_time_log(args):
|
||||
|
||||
@@ -10,6 +10,7 @@ from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||
NotInWorkingHoursError,
|
||||
WorkstationHolidayError,
|
||||
check_if_within_operating_hours,
|
||||
update_job_card,
|
||||
)
|
||||
|
||||
test_dependencies = ["Warehouse"]
|
||||
@@ -17,7 +18,22 @@ test_records = frappe.get_test_records("Workstation")
|
||||
make_test_records("Workstation")
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
class TestWorkstation(FrappeTestCase):
|
||||
=======
|
||||
class TestWorkstation(ERPNextTestSuite):
|
||||
def test_update_job_card_rejects_disallowed_method(self):
|
||||
# The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card
|
||||
# methods. An arbitrary method name must be rejected (PermissionError) before the document
|
||||
# is even loaded, so this needs no Job Card to exist.
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
update_job_card,
|
||||
"NON-EXISTENT-JOB-CARD",
|
||||
"delete",
|
||||
)
|
||||
|
||||
>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)
|
||||
def test_validate_timings(self):
|
||||
check_if_within_operating_hours(
|
||||
"_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00"
|
||||
|
||||
@@ -409,3 +409,78 @@ def get_workstations(**kwargs):
|
||||
d.status_image = d.off_status_image
|
||||
|
||||
return data
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
|
||||
def get_color_map():
|
||||
return {
|
||||
"Production": "green",
|
||||
"Off": "gray",
|
||||
"Idle": "gray",
|
||||
"Problem": "red",
|
||||
"Maintenance": "yellow",
|
||||
"Setup": "blue",
|
||||
}
|
||||
|
||||
|
||||
ALLOWED_JOB_CARD_METHODS = frozenset(
|
||||
{
|
||||
"start_timer",
|
||||
"pause_job",
|
||||
"resume_job",
|
||||
"complete_job_card",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_job_card(job_card: str, method: str, **kwargs):
|
||||
if method not in ALLOWED_JOB_CARD_METHODS:
|
||||
frappe.throw(
|
||||
_("Method {0} is not allowed to be run on a Job Card.").format(bold(method)),
|
||||
frappe.PermissionError,
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
|
||||
frappe.has_permission("Job Card", "read", throw=True)
|
||||
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
|
||||
# These methods mutate the Job Card, but frappe.get_doc does not enforce permissions —
|
||||
# require write access before running anything.
|
||||
frappe.has_permission("Job Card", "write", doc=doc, throw=True)
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
if kwargs.get("employees"):
|
||||
kwargs.employees = frappe.parse_json(kwargs.employees)
|
||||
|
||||
if kwargs.qty and isinstance(kwargs.qty, str):
|
||||
kwargs.qty = flt(kwargs.qty)
|
||||
|
||||
doc.run_method(method, **kwargs)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_job_card(job_card: str, status: str):
|
||||
job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1)
|
||||
|
||||
current_status = job_card_details.status
|
||||
if current_status != status:
|
||||
if status == "Open":
|
||||
frappe.throw(
|
||||
_("The job card {0} is in {1} state and you cannot start it again.").format(
|
||||
job_card, current_status
|
||||
)
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("The job card {0} is in {1} state and you cannot complete.").format(
|
||||
job_card, current_status
|
||||
)
|
||||
)
|
||||
|
||||
return job_card_details.for_quantity
|
||||
>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)
|
||||
|
||||
Reference in New Issue
Block a user