Fix won/lost checkboxes in Sales Invoice for gambling companies

- Rewrote won/lost checkbox logic with clean code approach
- Removed complex HTML synchronization in favor of native Frappe checkboxes
- Added CSS to display checkboxes inline (side by side) instead of stacked
- Implemented automatic enforcement: one checkbox must always be selected
- Added client-side validation that auto-corrects invalid states
- Added server-side validation for gambling companies only
- Removed old column break and section break fields
- Fields now only validate for gambling company main activities

Changes:
- custom_fields.py: Simplified won/lost field definitions, removed HTML field
- sales_invoice_vat.js: Clean logic + CSS for inline display + auto-selection
- sales_invoice.py: Server validation with auto-correction
- hooks.py: Added validate_won_lost_fields hook
- patches: Added cleanup patches for old fields

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ali 2026-02-16 17:21:09 +04:00
parent 4d1739d352
commit e68a1deb4c
7 changed files with 174 additions and 132 deletions

View File

@ -3,6 +3,36 @@ import json
from frappe.utils import flt from frappe.utils import flt
def validate_won_lost_fields(doc, method=None):
"""
Ensure that exactly one of won/lost checkboxes is always selected for gambling companies
Automatically fixes the issue instead of throwing an error
"""
if doc.doctype != "Sales Invoice":
return
# Check if this is a gambling company
allowed_activities = ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"]
company_main_activity = doc.get('company_main_activity')
if not company_main_activity or company_main_activity not in allowed_activities:
# Not a gambling company - skip validation
return
# Check if both fields exist
if not hasattr(doc, 'won') or not hasattr(doc, 'lost'):
return
# Для gambling компаний - автоматически исправляем ситуацию
if not doc.won and not doc.lost:
# Если обе выключены - включаем won по умолчанию
doc.won = 1
doc.lost = 0
elif doc.won and doc.lost:
# Если обе включены - оставляем только won
doc.lost = 0
def calculate_tax_free_amounts(doc, method=None): def calculate_tax_free_amounts(doc, method=None):
""" """
Calculate tax free amounts for Sales Invoice Calculate tax free amounts for Sales Invoice

View File

@ -607,43 +607,26 @@ def create_custom_fields():
insert_after='act_type', insert_after='act_type',
depends_on='eval:doc.purchase_type && doc.act_type == "Import"' depends_on='eval:doc.purchase_type && doc.act_type == "Import"'
), ),
# Won and Lost checkboxes after section_break_42 (только HTML для v15) # Won and Lost checkboxes - в одну строку
dict(
fieldname='won_lost_html',
fieldtype='HTML',
insert_after='section_break_42',
depends_on='eval:doc.company_main_activity && ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"].includes(doc.company_main_activity)',
options="""
<div style="display: flex; gap: 50px; margin: 8px 0;">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="won-checkbox" checked style="margin: 0;">
<span style="font-weight: 500;">Won</span>
</label>
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="lost-checkbox" style="margin: 0;">
<span style="font-weight: 500;">Lost</span>
</label>
</div>
"""
),
# Скрытые поля для хранения значений
dict( dict(
fieldname='won', fieldname='won',
label='Won', label='Won',
fieldtype='Check', fieldtype='Check',
insert_after='won_lost_html', insert_after='section_break_42',
hidden=1, depends_on='eval:doc.company_main_activity && ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"].includes(doc.company_main_activity)',
default=1, default=1,
allow_on_submit=1 allow_on_submit=1,
mandatory_depends_on='eval:doc.company_main_activity && ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"].includes(doc.company_main_activity)'
), ),
dict( dict(
fieldname='lost', fieldname='lost',
label='Lost', label='Lost',
fieldtype='Check', fieldtype='Check',
insert_after='won', insert_after='won',
hidden=1, depends_on='eval:doc.company_main_activity && ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"].includes(doc.company_main_activity)',
default=0, default=0,
allow_on_submit=1 allow_on_submit=1,
mandatory_depends_on='eval:doc.company_main_activity && ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"].includes(doc.company_main_activity)'
), ),
# New comments section after time_sheet_list # New comments section after time_sheet_list
dict( dict(

View File

@ -34,8 +34,12 @@ doc_events = {
}, },
"Sales Invoice": { "Sales Invoice": {
"validate": [ "validate": [
"jey_erp.custom.sales_invoice.validate_won_lost_fields",
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts", "jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
], ],
"before_save": [
"jey_erp.custom.sales_invoice.validate_won_lost_fields",
],
"on_update": [ "on_update": [
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts", "jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
] ]

View File

@ -5,3 +5,5 @@
[post_model_sync] [post_model_sync]
# Patches added in this section will be executed after doctypes are migrated # Patches added in this section will be executed after doctypes are migrated
jey_erp.patches.create_asset_categories_post_migration jey_erp.patches.create_asset_categories_post_migration
jey_erp.patches.remove_old_won_lost_fields
jey_erp.patches.remove_won_lost_breaks

View File

@ -0,0 +1,17 @@
import frappe
def execute():
"""Remove old won_lost_html field if it exists"""
# Remove old HTML field
if frappe.db.exists("Custom Field", {"dt": "Sales Invoice", "fieldname": "won_lost_html"}):
try:
custom_field_name = frappe.db.get_value("Custom Field",
{"dt": "Sales Invoice", "fieldname": "won_lost_html"}, "name")
frappe.delete_doc("Custom Field", custom_field_name)
frappe.db.commit()
print("Deleted old won_lost_html field")
except Exception as e:
print(f"Failed to delete won_lost_html: {e}")
print("Cleanup completed")

View File

@ -0,0 +1,22 @@
import frappe
def execute():
"""Remove old column break and section break fields for won/lost"""
fields_to_remove = [
"won_lost_column_break",
"won_lost_section_break_end"
]
for fieldname in fields_to_remove:
if frappe.db.exists("Custom Field", {"dt": "Sales Invoice", "fieldname": fieldname}):
try:
custom_field_name = frappe.db.get_value("Custom Field",
{"dt": "Sales Invoice", "fieldname": fieldname}, "name")
frappe.delete_doc("Custom Field", custom_field_name)
print(f"Deleted field: {fieldname}")
except Exception as e:
print(f"Failed to delete {fieldname}: {e}")
frappe.db.commit()
print("Cleanup completed")

View File

@ -1,13 +1,15 @@
// Запускаем логику калькулятора НДС для Sales Invoice // Запускаем логику калькулятора НДС для Sales Invoice
jey_erp.vat_calculator.init('Sales Invoice'); jey_erp.vat_calculator.init('Sales Invoice');
// Логика для чекбоксов Won/Lost и Seller/Organizer // Простая логика для чекбоксов Won/Lost и Seller/Organizer
frappe.ui.form.on('Sales Invoice', { frappe.ui.form.on('Sales Invoice', {
refresh: function(frm) { refresh: function(frm) {
// Убедимся что одна из галочек Won/Lost всегда выбрана при загрузке формы // Убедимся что одна из галочек Won/Lost выбрана (для gambling компаний)
if (is_gambling_company(frm)) {
if (!frm.doc.won && !frm.doc.lost) { if (!frm.doc.won && !frm.doc.lost) {
frm.set_value('won', 1); frm.set_value('won', 1);
} }
}
// Убедимся что одна из галочек Seller/Organizer выбрана (если поля видимы) // Убедимся что одна из галочек Seller/Organizer выбрана (если поля видимы)
if (should_show_seller_organizer(frm)) { if (should_show_seller_organizer(frm)) {
@ -16,40 +18,73 @@ frappe.ui.form.on('Sales Invoice', {
} }
} }
// Настройка HTML чекбоксов после загрузки формы // Применяем CSS для отображения Won и Lost в одну строку близко друг к другу
setTimeout(function() { apply_inline_checkbox_style(frm);
setup_won_lost_html_checkboxes(frm);
}, 1000);
}, },
// Обработчики для Won/Lost onload: function(frm) {
// Применяем стиль при загрузке формы
apply_inline_checkbox_style(frm);
},
// Обработчики для Won/Lost - гарантируем что всегда одна галочка включена
won: function(frm) { won: function(frm) {
// Синхронизируем HTML чекбоксы при изменении скрытых полей if (!is_gambling_company(frm)) return;
update_html_checkboxes(frm);
if (frm.doc.won) { if (frm.doc.won) {
// Включили won - выключаем lost
if (frm.doc.lost) {
frm.set_value('lost', 0); frm.set_value('lost', 0);
}
} else { } else {
// Выключили won - автоматически включаем lost
frm.set_value('lost', 1); frm.set_value('lost', 1);
} }
}, },
lost: function(frm) { lost: function(frm) {
// Синхронизируем HTML чекбоксы при изменении скрытых полей if (!is_gambling_company(frm)) return;
update_html_checkboxes(frm);
if (frm.doc.lost) { if (frm.doc.lost) {
// Включили lost - выключаем won
if (frm.doc.won) {
frm.set_value('won', 0); frm.set_value('won', 0);
}
} else { } else {
// Выключили lost - автоматически включаем won
frm.set_value('won', 1); frm.set_value('won', 1);
} }
}, },
before_save: function(frm) {
// Перед сохранением убеждаемся что одна галочка включена
if (is_gambling_company(frm)) {
if (!frm.doc.won && !frm.doc.lost) {
frm.set_value('won', 1);
}
}
},
validate: function(frm) {
// Валидация для gambling компаний - автоматически исправляем
if (is_gambling_company(frm)) {
if (!frm.doc.won && !frm.doc.lost) {
// Если обе выключены, включаем won
frm.set_value('won', 1);
} else if (frm.doc.won && frm.doc.lost) {
// Если обе включены, оставляем только won
frm.set_value('lost', 0);
}
}
},
// Обработчики для Seller/Organizer // Обработчики для Seller/Organizer
seller: function(frm) { seller: function(frm) {
if (should_show_seller_organizer(frm)) { if (should_show_seller_organizer(frm)) {
if (frm.doc.seller) { if (frm.doc.seller) {
frm.set_value('organizer', 0); frm.set_value('organizer', 0);
} else { } else if (!frm.doc.organizer) {
// Если seller снят, обязательно ставим organizer // Если seller снят и organizer тоже не выбран, ставим organizer
frm.set_value('organizer', 1); frm.set_value('organizer', 1);
} }
} }
@ -59,8 +94,8 @@ frappe.ui.form.on('Sales Invoice', {
if (should_show_seller_organizer(frm)) { if (should_show_seller_organizer(frm)) {
if (frm.doc.organizer) { if (frm.doc.organizer) {
frm.set_value('seller', 0); frm.set_value('seller', 0);
} else { } else if (!frm.doc.seller) {
// Если organizer снят, обязательно ставим seller // Если organizer снят и seller тоже не выбран, ставим seller
frm.set_value('seller', 1); frm.set_value('seller', 1);
} }
} }
@ -72,97 +107,46 @@ frappe.ui.form.on('Sales Invoice', {
if (should_show_seller_organizer(frm)) { if (should_show_seller_organizer(frm)) {
if (!frm.doc.seller && !frm.doc.organizer) { if (!frm.doc.seller && !frm.doc.organizer) {
frm.set_value('organizer', 1); frm.set_value('organizer', 1);
frm.set_value('seller', 0);
}
}
},
// При валидации формы
validate: function(frm) {
// Проверяем Won/Lost
if (!frm.doc.won && !frm.doc.lost) {
frm.set_value('won', 1);
frm.set_value('lost', 0);
}
// Проверяем Seller/Organizer (если поля должны быть видимы)
if (should_show_seller_organizer(frm)) {
if (!frm.doc.seller && !frm.doc.organizer) {
frm.set_value('organizer', 1);
frm.set_value('seller', 0);
} }
} }
} }
}); });
// Функция проверки, должны ли быть видимы поля Seller/Organizer // Функция проверки, является ли компания gambling компанией
function should_show_seller_organizer(frm) { function is_gambling_company(frm) {
const allowed_activities = ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"]; const allowed_activities = ["92000", "9200003", "9200004", "9200001", "9200002", "9200005"];
return frm.doc.company_main_activity && return frm.doc.company_main_activity &&
allowed_activities.includes(frm.doc.company_main_activity); allowed_activities.includes(frm.doc.company_main_activity);
} }
// Функция настройки HTML чекбоксов для Won/Lost // Функция проверки, должны ли быть видимы поля Seller/Organizer (то же самое условие)
function setup_won_lost_html_checkboxes(frm) { function should_show_seller_organizer(frm) {
const wonCheckbox = document.querySelector('.won-checkbox'); return is_gambling_company(frm);
const lostCheckbox = document.querySelector('.lost-checkbox');
if (!wonCheckbox || !lostCheckbox) return;
// Синхронизация с текущими значениями полей
wonCheckbox.checked = frm.doc.won ? true : false;
lostCheckbox.checked = frm.doc.lost ? true : false;
// Обеспечиваем что всегда выбрана одна галочка
if (!wonCheckbox.checked && !lostCheckbox.checked) {
wonCheckbox.checked = true;
frm.set_value('won', 1);
}
// Удаляем старые обработчики событий
wonCheckbox.removeEventListener('change', wonCheckbox._changeHandler);
lostCheckbox.removeEventListener('change', lostCheckbox._changeHandler);
// Добавляем новые обработчики событий
wonCheckbox._changeHandler = function() {
if (this.checked) {
lostCheckbox.checked = false;
frm.set_value('lost', 0);
frm.set_value('won', 1);
} else {
lostCheckbox.checked = true;
frm.set_value('lost', 1);
frm.set_value('won', 0);
}
};
lostCheckbox._changeHandler = function() {
if (this.checked) {
wonCheckbox.checked = false;
frm.set_value('won', 0);
frm.set_value('lost', 1);
} else {
wonCheckbox.checked = true;
frm.set_value('won', 1);
frm.set_value('lost', 0);
}
};
wonCheckbox.addEventListener('change', wonCheckbox._changeHandler);
lostCheckbox.addEventListener('change', lostCheckbox._changeHandler);
} }
// Функция синхронизации HTML чекбоксов со скрытыми полями // Функция для применения CSS стилей чтобы чекбоксы были в одну строку
function update_html_checkboxes(frm) { function apply_inline_checkbox_style(frm) {
setTimeout(function() { // Добавляем CSS если еще не добавлен
const wonCheckbox = document.querySelector('.won-checkbox'); if (!document.getElementById('won-lost-inline-style')) {
const lostCheckbox = document.querySelector('.lost-checkbox'); const style = document.createElement('style');
style.id = 'won-lost-inline-style';
if (wonCheckbox && lostCheckbox) { style.textContent = `
wonCheckbox.checked = frm.doc.won ? true : false; /* Делаем Won и Lost в одну строку */
lostCheckbox.checked = frm.doc.lost ? true : false; [data-fieldname="won"],
[data-fieldname="lost"] {
display: inline-block !important;
width: auto !important;
margin-right: 30px !important;
vertical-align: top !important;
}
/* Убираем перенос строки после Won */
[data-fieldname="won"] + [data-fieldname="lost"] {
margin-left: 0 !important;
}
`;
document.head.appendChild(style);
} }
}, 100);
} }
// Функция синхронизации фактических сумм НДС из items в taxes // Функция синхронизации фактических сумм НДС из items в taxes