Allow Azerbaijani abbreviation, FY end auto-fill, drop inactive rows

Four pieces of polish requested after the wizard otherwise lands clean:

1. Abbreviation accepts any Unicode letter
   The validator regex /^[A-Z0-9 ]{1,10}$/ rejected legitimate
   Azerbaijani uppercase characters (Ə Ğ İ Ö Ş Ü Ç) the user may
   want in their abbr. Switched to /^[\p{L}\p{N} ]{1,10}$/u so any
   letter / digit goes through; toUpperCase() in modern browsers
   already handles the AZ casing correctly. Error string updated to
   match.

2. Fiscal Year end auto-fills from start
   When the Frappe Date control on the start field fires `change`,
   we set end = start + 1 year - 1 day via moment. The reverse
   trigger (end → start) is intentionally absent so editing end
   never overwrites the user's chosen start.

3. Inactive rows removed after the e-taxes loaders
   New _purge_inactive_etaxes_records(company) runs right after the
   "loaders done" trace. invoice_az writes everything, regardless
   of status; the wizard's contract is "active rows only", so we
   prune:
     - E-Taxes Bank Account where status='C' (closed)
     - E-Taxes Object where object_status not in (A,active)
     - E-Taxes Cash Register where status_code not in (A,active)
     - E-Taxes POS Terminal where status not in (A,active)
     - E-Taxes Obligation Pact where expire_date < today
   Presented Certificates carry historical state and stay. Per-row
   delete failures logged. New trace "inactive purge done" reports
   the matched/deleted counts per category.

4. ƏMAS: filter terminated/suspended at cache time
   cache_selected_employees now drops anything whose contract_status
   isn't in {qüvvədədir, aktiv, active}. Empty status passes through
   so we never silently lose someone (e.g. a row from get_employees_
   report that omitted the field). The bulk import job hits AMAS
   once per row, so cutting these here saves real wall time on the
   post-finalize background work.

Background-import architecture explained in chat — the per-employee
sync via _process_bulk_employees_import (one CSRF + AMAS detail call
per row, sequential) is by design in invoice_az; jey_wizard fires
and forgets it.
This commit is contained in:
Ali 2026-04-30 14:06:32 +00:00
parent 0e7ce08d7e
commit 38293a8de5
4 changed files with 163 additions and 7 deletions

View File

@ -1 +1 @@
__version__ = "0.1.17"
__version__ = "0.1.18"

View File

@ -16,10 +16,34 @@ import frappe
from frappe import _
# AMAS contract_status values that count as "still on payroll". Anything else
# (terminated/suspended/blank) is filtered out before we cache, so the bulk
# import job in invoice_az never even sees them.
_ACTIVE_AMAS_STATUSES = {
"qüvvədədir",
"aktiv",
"active",
}
def _is_active_amas_employee(emp):
if not isinstance(emp, dict):
return False
raw = str(emp.get("contract_status") or "").strip().lower()
# Empty status → trust the row (be conservative: import unknown rather
# than silently drop someone who would have been active).
if not raw:
return True
return raw in _ACTIVE_AMAS_STATUSES
@frappe.whitelist()
def cache_selected_employees(employees_json, create_designation=0):
"""Store the selected ƏMAS employees in the wizard cache so materialize_after_setup
can enqueue the bulk import with the freshly-created Company.
Drops terminated / suspended contracts up front: import_bulk_employees hits
the AMAS API once per row, so cutting them here saves real time.
"""
_only_admin()
@ -28,6 +52,10 @@ def cache_selected_employees(employees_json, create_designation=0):
else:
parsed = employees_json or []
original_count = len(parsed)
parsed = [e for e in parsed if _is_active_amas_employee(e)]
dropped = original_count - len(parsed)
try:
create_designation = int(create_designation)
except (ValueError, TypeError):
@ -44,7 +72,7 @@ def cache_selected_employees(employees_json, create_designation=0):
cache.save()
frappe.db.commit()
return {"ok": True, "count": len(parsed)}
return {"ok": True, "count": len(parsed), "dropped_inactive": dropped}
@frappe.whitelist()

View File

@ -347,6 +347,12 @@ def materialize_after_setup(args):
"Jey Wizard trace: loaders done",
)
# invoice_az's loaders write everything they receive — closed bank
# accounts, terminated objects, etc. The wizard's contract is "active
# data only", so prune the dead rows here. Best-effort: per-row failures
# are logged, not raised.
_purge_inactive_etaxes_records(company_name)
# invoice_az's bank loader only populates the E-Taxes Bank Account cache doctype.
# For the wizard we also want native ERPNext Bank / Bank Account / GL Account so
# the user can post transactions on day one, plus we clear out unused CoA
@ -1102,6 +1108,107 @@ def _find_default_warehouse(company):
)
def _purge_inactive_etaxes_records(company):
"""Remove inactive rows from the E-Taxes doctypes that invoice_az's
loaders just populated. Active-status semantics per doctype:
- E-Taxes Bank Account: status='C' means closed (matches the same
filter we already apply in _materialize_native_banks).
- E-Taxes Object: object_status='A' or 'active' means active (mirrors
invoice_az.send_sales_api which filters status=='A' before send).
- E-Taxes Cash Register: status_code='A' means active.
- E-Taxes POS Terminal: status='A' means active.
- E-Taxes Obligation Pact: rows with expire_date in the past are
expired drop.
Presented Certificates carry historical state (cert_state) but are
primarily an audit record we leave them alone.
Only deletes rows belonging to the freshly-created Company so prior
data on the site stays untouched if anyone re-runs the materialize.
"""
from frappe.utils import getdate, today
report = {}
def _delete_where(doctype, filters, label):
try:
rows = frappe.get_all(doctype, filters=filters, pluck="name")
except Exception as exc:
frappe.log_error(
f"_purge_inactive_etaxes_records: list {doctype}: {exc}",
"Jey Wizard purge inactive",
)
report[label] = {"error": str(exc)[:200]}
return
deleted = 0
for name in rows:
try:
frappe.delete_doc(doctype, name, ignore_permissions=True, delete_permanently=True)
deleted += 1
except Exception as exc:
frappe.log_error(
f"_purge_inactive_etaxes_records: delete {doctype}/{name}: {exc}",
"Jey Wizard purge inactive",
)
report[label] = {"deleted": deleted, "matched": len(rows)}
# Closed bank accounts (status="C")
_delete_where(
"E-Taxes Bank Account",
{"company": company, "status": "C"},
"bank_accounts_closed",
)
# Inactive objects (anything that's NOT 'A'/'active')
_delete_where(
"E-Taxes Object",
{"company": company, "object_status": ("not in", ("A", "active", "Active"))},
"objects_inactive",
)
# Inactive cash registers (status_code anything but 'A')
_delete_where(
"E-Taxes Cash Register",
{"company": company, "status_code": ("not in", ("A", "active", "Active"))},
"cash_registers_inactive",
)
# Inactive POS terminals (status anything but 'A')
_delete_where(
"E-Taxes POS Terminal",
{"company": company, "status": ("not in", ("A", "active", "Active"))},
"pos_terminals_inactive",
)
# Expired obligation pacts: expire_date strictly in the past
try:
expired = frappe.get_all(
"E-Taxes Obligation Pact",
filters={"company": company, "expire_date": ("<", today())},
pluck="name",
)
deleted = 0
for name in expired:
try:
frappe.delete_doc("E-Taxes Obligation Pact", name, ignore_permissions=True, delete_permanently=True)
deleted += 1
except Exception as exc:
frappe.log_error(
f"_purge_inactive_etaxes_records: delete pact/{name}: {exc}",
"Jey Wizard purge inactive",
)
report["obligation_pacts_expired"] = {"deleted": deleted, "matched": len(expired)}
except Exception as exc:
report["obligation_pacts_expired"] = {"error": str(exc)[:200]}
frappe.db.commit()
frappe.log_error(
json.dumps(report, ensure_ascii=False, indent=2),
"Jey Wizard trace: inactive purge done",
)
def _drop_alert_messages():
"""Wipe everything in frappe.local.message_log that piled up during the
setup_complete request. We need the entire log gone, not just alerts:

View File

@ -10,7 +10,7 @@ frappe.provide("jey_wizard");
// Bump this string in every commit that changes wizard code. Displayed in the badge so
// we can tell at a glance which version is actually running on a given machine. Kept in
// sync with __version__ in jey_wizard/__init__.py.
const JEY_WIZARD_VERSION = "0.1.17";
const JEY_WIZARD_VERSION = "0.1.18";
// Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners
// don't try to mutate a wizard that isn't slide-based anymore.
@ -467,6 +467,23 @@ frappe.setup.SetupWizard = class JeySetupWizard {
this.fy_start_ctrl = mountDate(".jey-fy-start", this.data.fy_start_date);
this.fy_end_ctrl = mountDate(".jey-fy-end", this.data.fy_end_date);
// Fiscal year is exactly 12 months: when the user picks a new start
// date, default end to start + 1 year - 1 day. The user can still
// override end manually after — and the reverse trigger (end → start)
// is intentionally absent so editing end never overwrites start.
if (this.fy_start_ctrl && this.fy_start_ctrl.$input) {
this.fy_start_ctrl.$input.on("change", () => {
const start = this.fy_start_ctrl.get_value();
if (!start) return;
const auto_end = moment(start, "YYYY-MM-DD")
.add(1, "year")
.subtract(1, "day")
.format("YYYY-MM-DD");
this.fy_end_ctrl && this.fy_end_ctrl.set_value(auto_end);
this.data.fy_end_date = auto_end;
});
}
$body.find(".jey-valuation-method").val(this.data.valuation_method || "FIFO");
$body.find(".jey-accounting-method").val(this.data.accounting_method || "Hesablama metodu");
// Show why the default landed where it did, only when the auto-pick
@ -500,6 +517,9 @@ frappe.setup.SetupWizard = class JeySetupWizard {
$nameEl.on("input", fillAbbrDefault);
$abbrEl.on("input", (e) => {
this.data.company_abbr_user_edited = true;
// .toUpperCase() in modern browsers handles Azerbaijani correctly
// (ə → Ə, i → İ, etc.). Don't strip non-ASCII — the regex below
// accepts any Unicode letter.
const v = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10);
this.data.company_abbr = v;
$(e.currentTarget).val(v);
@ -691,10 +711,11 @@ frappe.setup.SetupWizard = class JeySetupWizard {
this.$wrap.find(".jey-status").text(__("Company abbreviation is required"));
return false;
}
// Allow letters, digits and a single space — e.g. "TS MMC" for
// the Azerbaijani quoted-core + legal-form pattern.
if (!/^[A-Z0-9 ]{1,10}$/.test(abbr)) {
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters/digits (AZ, 09, spaces allowed)."));
// Allow any Unicode letters (incl. Azerbaijani Ə Ğ İ Ö Ş Ü Ç),
// digits, and spaces. Length 110. The `u` flag is required for
// \p{L} / \p{N} to match.
if (!/^[\p{L}\p{N} ]{1,10}$/u.test(abbr)) {
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters or digits (any language, spaces allowed)."));
return false;
}
this.data.company_abbr = abbr;