Pull /v2/profile during Asan step + fill Company Tax Policy on finalize

Mirrors the manual "Update from E-Taxes" flow that already lives in
taxes_az/client/company.js — but unattended, as part of the wizard.

Asan step:
  fetch_company_profile() (new whitelisted method, jey_wizard.etaxes)
  wraps taxes_az.company_etaxes.get_company_profile, which hits
  /api/po/profile/public/v2/profile with the main_token. Same call
  also resolves taxAuthorityCode and propertyType to display names
  via the e-taxes dictionaries (token still fresh here — by finalize
  it may have expired). The merged blob is cached in a new
  Jey Wizard Etaxes Cache.company_profile_json field so finalize can
  apply it without another network round-trip.

  Wire-up: fetch_etaxes_data → fetch_company_profile, runs back-to-
  back during the existing "fetching" sub-state. Profile pull is
  best-effort — failure doesn't block the rest of the flow.

Finalize:
  _apply_company_profile(company) parses the cached blob, builds a
  field_updates dict mirroring the JS _parseProfileData mapping
  (BASIC, MANAGEMENT, BUSINESS/TAX INFO, VAT INFO, ORG STRUCTURE,
  REGISTRATION DATES, ADDRESSES, TAX SYSTEMS, EMPLOYER) plus the
  resolved tax_authority/property_type names. Date fields are
  normalised: YYYYMMDD compact dates → YYYY-MM-DD; ISO datetimes →
  YYYY-MM-DD prefix. Booleans → "1"/"0" so Check fields take them.

  Actual DB writes go through taxes_az.company_etaxes.force_update_
  company so the table-child handling for additionalActivityCodes
  and affiliatesNames/affiliatesTins stays in one place.

Schema: Jey Wizard Etaxes Cache picks up two new fields
  (company_profile_section, company_profile_json) — needs `bench
  migrate` on existing sites.

Trace: two new breadcrumbs in Error Log,
  "Jey Wizard trace: fetch_company_profile done" and
  "Jey Wizard trace: company profile applied" (with the list of
  fieldnames written and the force_update_company result summary).
This commit is contained in:
Ali 2026-04-29 10:07:28 +00:00
parent d93a024ba8
commit 89ae5e237c
4 changed files with 356 additions and 4 deletions

View File

@ -1 +1 @@
__version__ = "0.1.12"
__version__ = "0.1.13"

View File

@ -136,6 +136,82 @@ def fetch_all_etaxes():
}
@frappe.whitelist()
def fetch_company_profile():
"""Pull /api/po/profile/public/v2/profile via taxes_az helper + resolve
taxAuthorityCode/propertyType to human names through the e-taxes
dictionaries. Stash the merged blob in cache.company_profile_json so the
finalize step can write it onto Company without another network round-trip
(by which point main_token may already be invalid).
"""
_only_admin()
from taxes_az.company_etaxes import (
get_company_profile,
get_tax_authority_name_by_code,
get_property_type_name_by_code,
)
resp = get_company_profile()
if not resp or not resp.get("success"):
return {
"ok": False,
"message": (resp or {}).get("message", "get_company_profile failed"),
"error": (resp or {}).get("error"),
}
profile_blob = resp.get("data") or {}
# taxes_az's helper sometimes wraps the actual profile in {"profile": {...}}.
# Mirror the JS unwrap (see updateCompanyFromProfile in taxes_az/client/company.js).
profile = profile_blob.get("profile") if isinstance(profile_blob, dict) and isinstance(profile_blob.get("profile"), dict) else profile_blob
# Resolve dictionary codes to display names while we still have a fresh
# main_token. Failures degrade gracefully: keep the raw code.
resolved = {}
tax_authority_code = profile.get("taxAuthorityCode") if isinstance(profile, dict) else None
if tax_authority_code:
try:
r = get_tax_authority_name_by_code(tax_authority_code)
if r and r.get("success"):
resolved["tax_authority"] = r.get("name") or tax_authority_code
except Exception as exc:
frappe.log_error(
f"tax_authority lookup failed for {tax_authority_code}: {exc}",
"Jey Wizard fetch_company_profile",
)
property_type_code = profile.get("propertyType") if isinstance(profile, dict) else None
if property_type_code:
try:
r = get_property_type_name_by_code(property_type_code)
if r and r.get("success"):
resolved["property_type"] = r.get("name") or property_type_code
except Exception as exc:
frappe.log_error(
f"property_type lookup failed for {property_type_code}: {exc}",
"Jey Wizard fetch_company_profile",
)
cache = frappe.get_single("Jey Wizard Etaxes Cache")
cache.company_profile_json = json.dumps(
{"profile": profile, "resolved": resolved},
ensure_ascii=False,
)
cache.flags.ignore_permissions = True
cache.save()
frappe.db.commit()
frappe.log_error(
json.dumps({
"has_profile": bool(profile),
"profile_keys": sorted(list(profile.keys())) if isinstance(profile, dict) else None,
"resolved": resolved,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: fetch_company_profile done",
)
return {"ok": True, "resolved": resolved}
@frappe.whitelist()
def get_cached(kind):
"""Return parsed cached data for a given kind. Used by future wizard steps that
@ -258,6 +334,8 @@ def materialize_after_setup(args):
# placeholders. Runs off the just-populated cache, not another e-taxes fetch.
_materialize_native_banks(company_name)
_apply_company_profile(company_name)
_materialize_amas_employees(company_name)
# Drop toast-style messages that piled up during the various install/translate
@ -620,6 +698,245 @@ def _currency_sort_key(acc):
return (1, 0, cur)
def _apply_company_profile(company):
"""Read cache.company_profile_json (populated during the Asan step by
fetch_company_profile) and write the Tax Policy fields onto Company.
Mapping mirrors taxes_az/client/company.js::_parseProfileData; we delegate
the actual write to taxes_az.company_etaxes.force_update_company so the
logic stays aligned with the in-app "Update from E-Taxes" button.
Best-effort: failures are logged, never raised.
"""
cache = frappe.get_single("Jey Wizard Etaxes Cache")
raw = (cache.company_profile_json or "").strip()
if not raw:
frappe.log_error(
f"company={company} reason=profile_cache_empty",
"Jey Wizard trace: company profile skipped",
)
return
try:
blob = json.loads(raw)
except Exception as exc:
frappe.log_error(
f"company={company} bad profile cache JSON: {exc}",
"Jey Wizard apply company profile",
)
return
profile = blob.get("profile") if isinstance(blob, dict) else None
resolved = (blob.get("resolved") if isinstance(blob, dict) else None) or {}
if not isinstance(profile, dict):
frappe.log_error(
f"company={company} no profile dict in cache",
"Jey Wizard apply company profile",
)
return
field_updates = _build_company_field_updates(profile, resolved)
additional_activities = []
if isinstance(profile.get("additionalActivityCodes"), list):
additional_activities = [c for c in profile["additionalActivityCodes"] if c]
affiliate_names = list(profile.get("affiliatesNames") or [])
affiliate_tins = list(profile.get("affiliatesTins") or [])
try:
from taxes_az.company_etaxes import force_update_company
result = force_update_company(
company_name=company,
field_updates=json.dumps(field_updates, ensure_ascii=False),
additional_activities=json.dumps(additional_activities, ensure_ascii=False),
affiliate_names=json.dumps(affiliate_names, ensure_ascii=False),
affiliate_tins=json.dumps(affiliate_tins, ensure_ascii=False),
) or {}
except Exception as exc:
frappe.log_error(
f"force_update_company failed for {company}: {exc}\n{traceback.format_exc()}",
"Jey Wizard apply company profile",
)
return
frappe.log_error(
json.dumps({
"company": company,
"updated_fields_count": len(field_updates),
"updated_field_names": sorted(list(field_updates.keys())),
"additional_activities": len(additional_activities),
"affiliate_organizations": max(len(affiliate_names), len(affiliate_tins)),
"force_update_result": {
k: result.get(k) for k in ("success", "message", "updated_fields", "additional_activities_added", "affiliates_added")
} if isinstance(result, dict) else None,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: company profile applied",
)
def _build_company_field_updates(profile, resolved):
"""Map the e-taxes /v2/profile JSON onto Company fieldnames. Mirrors the
JS function _parseProfileData in taxes_az/client/company.js: same source
keys, same target fieldnames, same value normalisation.
"""
updates = {}
def _put(field, value):
if value is None:
return
# Booleans → "1"/"0" so the same code path works for Check fields.
if isinstance(value, bool):
updates[field] = "1" if value else "0"
return
s = str(value).strip()
if s == "":
return
updates[field] = s
def _fmt_compact_date(s):
# E-Taxes uses YYYYMMDD for VAT dates (see formatDate in JS).
if not s or len(str(s)) != 8:
return None
s = str(s)
return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
def _fmt_iso_date(s):
# Other dates come as ISO already; keep YYYY-MM-DD prefix only.
if not s:
return None
return str(s)[:10]
# BASIC
_put("tax_id", profile.get("tin"))
_put("registration_details", profile.get("stateRegistrationDocumentNumber"))
mobile = profile.get("mobilePhoneNumber") or _extract_phone(profile, "mobile")
landline = profile.get("landlinePhoneNumber") or _extract_phone(profile, "landline")
if mobile:
_put("phone_no", mobile)
elif landline:
_put("phone_no", landline)
_put("mobile_phone", mobile)
_put("landline_phone", landline)
_put("email", profile.get("email"))
_put("date_of_establishment", _fmt_iso_date(profile.get("taxpayerRegistrationDate")))
_put("state_registration_document_issued_date", _fmt_iso_date(profile.get("stateRegistrationDocumentIssuedDate")))
if profile.get("mainActivity"):
_put("domain", profile.get("mainActivity"))
_put("main_activity", profile.get("mainActivity"))
# MANAGEMENT
_put("director_name_etaxes", profile.get("companyDirectorName"))
_put("director_pin", profile.get("pin"))
# BUSINESS / TAX INFO
_put("legal_form_code", profile.get("legalFormCode"))
tax_rate = profile.get("taxRate")
if tax_rate:
if tax_rate in ("ƏDV", "VAT", "18%"):
_put("taxation_system", "18% Value-added tax")
elif tax_rate in ("0%", "ƏDV 0%"):
_put("taxation_system", "0% Value-added tax")
else:
_put("taxation_system", tax_rate)
criteria = profile.get("criteriaOfBusinessEntity")
if criteria:
c = str(criteria).lower()
mapping = {"micro": "Micro Entrepreneur", "small": "Small Entrepreneur",
"middle": "Medium Entrepreneur", "medium": "Medium Entrepreneur",
"big": "Big Entrepreneur", "large": "Big Entrepreneur"}
_put("business_classification", mapping.get(c, f"{criteria} Entrepreneur"))
_put("state_registration_authority", profile.get("stateRegistrationName"))
_put("taxpayer_activity_group", profile.get("activityGroup"))
if profile.get("specialTaxRegime") is not None:
_put("special_tax_regime", bool(profile.get("specialTaxRegime")))
_put("tin_type", profile.get("tinType"))
if profile.get("isRisky") is not None:
_put("is_risky_taxpayer", bool(profile.get("isRisky")))
if profile.get("sportBettingOperator") is not None:
_put("is_sport_betting_operator", bool(profile.get("sportBettingOperator")))
if profile.get("hasActiveProductionObject") is not None:
_put("has_active_production_object", bool(profile.get("hasActiveProductionObject")))
_put("tax_system_type", profile.get("taxSystemType"))
if profile.get("isTaxPayerInCancellationProcess") is not None:
_put("is_taxpayer_in_cancellation_process", bool(profile.get("isTaxPayerInCancellationProcess")))
if profile.get("isChiefOfAnyLegalEntity") is not None:
_put("is_chief_of_any_legal_entity", bool(profile.get("isChiefOfAnyLegalEntity")))
# VAT INFO (sub-object)
vat_info = profile.get("vatInfo")
if isinstance(vat_info, dict):
_put("vat_registration_date", _fmt_compact_date(vat_info.get("prOperationTableOperationDate")))
_put("vat_certificate_number", vat_info.get("recDocumentDocumentNumber"))
_put("vat_certificate_date", _fmt_compact_date(vat_info.get("prVatOperationsRegDate")))
# ORG STRUCTURE
head_org = profile.get("headOrganizationName")
if isinstance(head_org, list) and head_org:
_put("parent_organization", ", ".join(str(x) for x in head_org if x))
_put("parent_organization_tin", profile.get("headOrganizationTin"))
if isinstance(profile.get("employeeData"), dict):
_put("employee_count", profile["employeeData"].get("employeeCount"))
# REGISTRATION DATES
_put("suspension_start_date", _fmt_iso_date(profile.get("suspensionStartDate")))
_put("suspension_end_date", _fmt_iso_date(profile.get("suspensionEndDate")))
_put("liquidation_date", _fmt_iso_date(profile.get("liquidationDate")))
# ADDRESSES
_put("legal_address_full", profile.get("legalAddress"))
_put("actual_address_full", profile.get("actualAddress"))
_put("address_for_mail", profile.get("addressForMail"))
addr = profile.get("legalAddressObject")
if isinstance(addr, dict):
_put("legal_address_postcode", addr.get("postcode"))
_put("legal_address_region", addr.get("region"))
_put("legal_address_locality", addr.get("locality"))
_put("legal_address_street", addr.get("street"))
_put("legal_address_house", addr.get("houseNumber"))
_put("legal_address_room", addr.get("roomNumber"))
# TAX SYSTEMS
tax_systems = profile.get("taxSystems")
if isinstance(tax_systems, list) and tax_systems:
_put("tax_systems_list", ", ".join(str(x) for x in tax_systems if x))
# EMPLOYER
emp = profile.get("employer")
if isinstance(emp, dict):
_put("employer_name", emp.get("name"))
_put("employer_position", emp.get("position"))
# Resolved dictionary names (set by fetch_company_profile)
if resolved.get("tax_authority"):
_put("tax_authority", resolved["tax_authority"])
if resolved.get("property_type"):
_put("property_type", resolved["property_type"])
return updates
def _extract_phone(profile, preferred=None):
"""Mirror of ETaxesCompany._extractPhoneNumber from taxes_az/client/company.js
picks the best phone from `contacts` when the top-level fields are blank.
"""
if preferred == "mobile" and profile.get("mobilePhoneNumber"):
return profile["mobilePhoneNumber"]
if preferred == "landline" and profile.get("landlinePhoneNumber"):
return profile["landlinePhoneNumber"]
if profile.get("mobilePhoneNumber"):
return profile["mobilePhoneNumber"]
if profile.get("landlinePhoneNumber"):
return profile["landlinePhoneNumber"]
contacts = profile.get("contacts")
if isinstance(contacts, list):
for wanted in ("mobile", "homePhone"):
for c in contacts:
if isinstance(c, dict) and c.get("type") == wanted and c.get("contact"):
prefix = c.get("prefix") or ""
return f"{prefix}{c['contact']}"
return None
def _materialize_amas_employees(company_name):
"""If the user loaded ƏMAS employees during the wizard, enqueue bulk import now
that Company exists. Skipped silently when the cache is empty (i.e. user skipped

View File

@ -16,6 +16,8 @@
"bank_accounts_json",
"obligation_pacts_json",
"presented_certs_json",
"company_profile_section",
"company_profile_json",
"amas_section",
"amas_selected_employees_json"
],
@ -91,6 +93,18 @@
"options": "JSON",
"read_only": 1
},
{
"fieldname": "company_profile_section",
"fieldtype": "Section Break",
"label": "Company Profile (Tax Policy)"
},
{
"fieldname": "company_profile_json",
"fieldtype": "Code",
"label": "Company Profile",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "amas_section",
"fieldtype": "Section Break",

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.12";
const JEY_WIZARD_VERSION = "0.1.13";
// 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.
@ -1004,18 +1004,39 @@ frappe.setup.SetupWizard = class JeySetupWizard {
}
fetch_etaxes_data() {
// Sequence: fetch_all_etaxes → fetch_company_profile. Both run with the
// same fresh main_token; the profile pull also does dictionary lookups
// (taxAuthority/propertyType) that need the token, so we want them done
// while the user is still on the "fetching" screen — by finalize-time
// the token may have expired.
frappe.call({
method: "jey_wizard.etaxes.fetch_all_etaxes",
callback: (r) => {
const m = r.message || {};
this.asan_fetch_summary = m.summary || {};
this.asan_fetch_errors = m.errors || {};
this.asan_state = "done";
this.show_current_step();
this.fetch_company_profile();
},
error: () => {
this.asan_fetch_summary = {};
this.asan_fetch_errors = { all: __("fetch call failed — see server logs") };
// Still try the profile pull — independent payload, useful even
// if the lists call hiccupped.
this.fetch_company_profile();
},
});
}
fetch_company_profile() {
frappe.call({
method: "jey_wizard.etaxes.fetch_company_profile",
callback: () => {
this.asan_state = "done";
this.show_current_step();
},
error: () => {
// Profile fetch is best-effort. Materialize will skip cleanly
// if the cache stays empty; nothing to surface to the user here.
this.asan_state = "done";
this.show_current_step();
},