654 lines
23 KiB
Python
654 lines
23 KiB
Python
import re
|
||
import logging
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime
|
||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||
from typing import Any, Callable
|
||
from urllib.parse import urlparse
|
||
|
||
from dateutil import parser as date_parser
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from commerce.integrations.moysklad import MoySkladClient
|
||
from commerce.media import sync_moysklad_product_images
|
||
from commerce.models import (
|
||
IntegrationConnection,
|
||
Product,
|
||
ProductBinding,
|
||
ProductKind,
|
||
Sale,
|
||
SaleEventType,
|
||
SaleLine,
|
||
StockSnapshot,
|
||
)
|
||
from commerce.services.credentials import decrypt_credentials
|
||
from commerce.services.ingestion import ingest_source_record
|
||
from commerce.services.matching import match_product
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class MoySkladSyncStats:
|
||
processed: int = 0
|
||
created_products: int = 0
|
||
matched_products: int = 0
|
||
stock_snapshots: int = 0
|
||
images: int = 0
|
||
image_errors: int = 0
|
||
sales: int = 0
|
||
sale_lines: int = 0
|
||
|
||
def to_dict(self) -> dict[str, int]:
|
||
return asdict(self)
|
||
|
||
|
||
def _external_id(row: dict[str, Any]) -> str:
|
||
value = str(row.get("id") or "").strip()
|
||
if value:
|
||
return value
|
||
href = str(row.get("meta", {}).get("href") or "").rstrip("/")
|
||
return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
|
||
|
||
|
||
def _barcode(row: dict[str, Any]) -> str:
|
||
for item in row.get("barcodes") or []:
|
||
if isinstance(item, str) and item.strip():
|
||
return item.strip()
|
||
if isinstance(item, dict):
|
||
for value in item.values():
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return ""
|
||
|
||
|
||
def _decimal(value: Any) -> Decimal:
|
||
try:
|
||
return Decimal(str(value or 0))
|
||
except (InvalidOperation, TypeError, ValueError):
|
||
return Decimal("0")
|
||
|
||
|
||
def _money(value: Any) -> Decimal:
|
||
return (_decimal(value) / Decimal("100")).quantize(
|
||
Decimal("0.01"),
|
||
rounding=ROUND_HALF_UP,
|
||
)
|
||
|
||
|
||
def _position_revenue(
|
||
document: dict[str, Any], position: dict[str, Any]
|
||
) -> tuple[Decimal, Decimal, Decimal]:
|
||
"""Return source-compatible gross, discount and added VAT amounts.
|
||
|
||
MoySklad may return a fractional price in kopecks. Rounding that unit price
|
||
before multiplying by quantity changes the document total. It may also keep
|
||
VAT outside the position price while including it in the document ``sum``.
|
||
All arithmetic therefore stays at source precision until the line total.
|
||
"""
|
||
|
||
quantity = _decimal(position.get("quantity"))
|
||
base_gross = quantity * _decimal(position.get("price")) / Decimal("100")
|
||
discount_percent = _decimal(position.get("discount"))
|
||
base_net = base_gross * (Decimal("100") - discount_percent) / Decimal("100")
|
||
multiplier = Decimal("1")
|
||
if document.get("vatIncluded") is False and bool(position.get("vatEnabled")):
|
||
multiplier += _decimal(position.get("vat")) / Decimal("100")
|
||
|
||
gross = (base_gross * multiplier).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
)
|
||
net = (base_net * multiplier).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
)
|
||
base_net_rounded = base_net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
return gross, max(Decimal("0"), gross - net), net - base_net_rounded
|
||
|
||
|
||
def _source_updated_at(row: dict[str, Any]) -> datetime | None:
|
||
value = row.get("updated")
|
||
if not value:
|
||
return None
|
||
try:
|
||
parsed = date_parser.parse(str(value))
|
||
except (TypeError, ValueError, OverflowError):
|
||
return None
|
||
if timezone.is_naive(parsed):
|
||
parsed = timezone.make_aware(parsed)
|
||
return parsed
|
||
|
||
|
||
def _product_kind(row: dict[str, Any]) -> str:
|
||
source_type = str(row.get("meta", {}).get("type") or "")
|
||
if source_type == "service":
|
||
return ProductKind.SERVICE
|
||
if source_type == "bundle":
|
||
return ProductKind.BUNDLE
|
||
return ProductKind.PRODUCT
|
||
|
||
|
||
def _reference_external_id(value: Any) -> str:
|
||
if not isinstance(value, dict):
|
||
return ""
|
||
direct = str(value.get("id") or "").strip()
|
||
if direct:
|
||
return direct
|
||
meta = value.get("meta") if isinstance(value.get("meta"), dict) else {}
|
||
href = str(meta.get("href") or "").rstrip("/")
|
||
return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
|
||
|
||
|
||
def _reference_name(value: Any) -> str:
|
||
return str(value.get("name") or "") if isinstance(value, dict) else ""
|
||
|
||
|
||
def _price_value(value: Any) -> str | None:
|
||
if not isinstance(value, dict) or "value" not in value:
|
||
return None
|
||
return str(_money(value.get("value")))
|
||
|
||
|
||
def _binding_metadata(row: dict[str, Any]) -> dict[str, Any]:
|
||
sale_prices = []
|
||
for price in row.get("salePrices") or []:
|
||
if not isinstance(price, dict):
|
||
continue
|
||
price_type = (
|
||
price.get("priceType") if isinstance(price.get("priceType"), dict) else {}
|
||
)
|
||
sale_prices.append(
|
||
{
|
||
"name": str(price_type.get("name") or ""),
|
||
"external_id": _reference_external_id(price_type),
|
||
"value": str(_money(price.get("value"))),
|
||
}
|
||
)
|
||
attributes = []
|
||
for attribute in row.get("attributes") or []:
|
||
if isinstance(attribute, dict):
|
||
attributes.append(
|
||
{
|
||
"external_id": str(attribute.get("id") or ""),
|
||
"name": str(attribute.get("name") or ""),
|
||
"type": str(attribute.get("type") or ""),
|
||
"value": attribute.get("value"),
|
||
}
|
||
)
|
||
return {
|
||
"provider": "moysklad",
|
||
"entity_type": str((row.get("meta") or {}).get("type") or ""),
|
||
"external_code": str(row.get("externalCode") or ""),
|
||
"parent_external_id": _reference_external_id(row.get("product")),
|
||
"purchase_price": _price_value(row.get("buyPrice")),
|
||
"minimum_price": _price_value(row.get("minPrice")),
|
||
"sale_prices": sale_prices,
|
||
"uom": {
|
||
"external_id": _reference_external_id(row.get("uom")),
|
||
"name": _reference_name(row.get("uom")),
|
||
},
|
||
"folder": {
|
||
"external_id": _reference_external_id(row.get("productFolder")),
|
||
"name": str(row.get("pathName") or ""),
|
||
},
|
||
"weight": row.get("weight"),
|
||
"volume": row.get("volume"),
|
||
"vat": row.get("effectiveVat", row.get("vat")),
|
||
"attributes": attributes,
|
||
"characteristics": row.get("characteristics") or [],
|
||
"source_updated_at": str(row.get("updated") or ""),
|
||
}
|
||
|
||
|
||
def _update_product_from_source(product: Product, row: dict[str, Any]) -> None:
|
||
external_id = _external_id(row)
|
||
external_sku = str(row.get("article") or row.get("code") or "").strip()
|
||
product.name = str(row.get("name") or external_sku or external_id)[:255]
|
||
product.barcode = _barcode(row)[:64]
|
||
product.description = str(row.get("description") or "")
|
||
product.kind = _product_kind(row)
|
||
product.is_active = not bool(row.get("archived"))
|
||
product.save(
|
||
update_fields=[
|
||
"name",
|
||
"barcode",
|
||
"description",
|
||
"kind",
|
||
"is_active",
|
||
"updated_at",
|
||
]
|
||
)
|
||
|
||
|
||
def _candidate_sku(row: dict[str, Any], external_id: str) -> str:
|
||
raw = str(row.get("article") or row.get("code") or "").strip()
|
||
if not raw:
|
||
raw = f"moysklad-{external_id}"
|
||
normalized = re.sub(r"\s+", "-", raw)[:128]
|
||
if not Product.objects.filter(sku__iexact=normalized).exists():
|
||
return normalized
|
||
suffix = external_id.replace("-", "")[:10]
|
||
base = normalized[: max(1, 117 - len(suffix))]
|
||
candidate = f"{base}-ms-{suffix}"[:128]
|
||
sequence = 2
|
||
while Product.objects.filter(sku__iexact=candidate).exists():
|
||
tail = f"-{sequence}"
|
||
candidate = f"{base[: 128 - len(tail)]}{tail}"
|
||
sequence += 1
|
||
return candidate
|
||
|
||
|
||
@transaction.atomic
|
||
def normalize_moysklad_assortment_row(
|
||
*, connection: IntegrationConnection, row: dict[str, Any], captured_at: datetime
|
||
) -> tuple[bool, bool, bool]:
|
||
source = connection.source
|
||
external_id = _external_id(row)
|
||
if not external_id:
|
||
return False, False, False
|
||
external_sku = str(row.get("article") or row.get("code") or "").strip()
|
||
barcode = _barcode(row)
|
||
ingest_source_record(
|
||
source=source,
|
||
entity_type="product",
|
||
external_id=external_id,
|
||
payload=row,
|
||
source_updated_at=_source_updated_at(row),
|
||
)
|
||
|
||
existing_binding = (
|
||
ProductBinding.objects.select_related("product")
|
||
.filter(source=source, external_id=external_id)
|
||
.first()
|
||
)
|
||
product = existing_binding.product if existing_binding else None
|
||
if product is None and str((row.get("meta") or {}).get("type") or "") != "variant":
|
||
candidate = match_product(
|
||
source=source,
|
||
external_id=external_id,
|
||
external_sku=external_sku,
|
||
barcode=barcode,
|
||
)
|
||
if candidate.product and not ProductBinding.objects.filter(
|
||
product=candidate.product,
|
||
source=source,
|
||
).exists():
|
||
product = candidate.product
|
||
created = False
|
||
matched = product is not None
|
||
if product is None:
|
||
product = Product.objects.create(
|
||
sku=_candidate_sku(row, external_id),
|
||
name=str(row.get("name") or external_sku or external_id)[:255],
|
||
barcode=barcode[:64],
|
||
description=str(row.get("description") or ""),
|
||
kind=_product_kind(row),
|
||
is_active=not bool(row.get("archived")),
|
||
)
|
||
created = True
|
||
|
||
if created or not product.bindings.exclude(source=source).exists():
|
||
_update_product_from_source(product, row)
|
||
|
||
binding, _ = ProductBinding.objects.update_or_create(
|
||
source=source,
|
||
external_id=external_id,
|
||
defaults={
|
||
"product": product,
|
||
"external_sku": external_sku[:128],
|
||
"metadata": _binding_metadata(row),
|
||
"is_primary": True,
|
||
},
|
||
)
|
||
parent_external_id = str(binding.metadata.get("parent_external_id") or "")
|
||
if parent_external_id:
|
||
parent_binding = ProductBinding.objects.filter(
|
||
source=source,
|
||
external_id=parent_external_id,
|
||
).first()
|
||
if parent_binding and parent_binding.product_id != product.pk:
|
||
product.parent_id = parent_binding.product_id
|
||
product.save(update_fields=["parent", "updated_at"])
|
||
has_stock = any(key in row for key in ("stock", "reserve", "inTransit"))
|
||
return created, matched, has_stock and product.kind != ProductKind.SERVICE
|
||
|
||
|
||
def reconcile_moysklad_variant_parents(source) -> int:
|
||
updated = 0
|
||
bindings = {
|
||
binding.external_id: binding
|
||
for binding in ProductBinding.objects.filter(source=source).select_related(
|
||
"product"
|
||
)
|
||
}
|
||
for binding in bindings.values():
|
||
parent_external_id = str(binding.metadata.get("parent_external_id") or "")
|
||
parent_binding = bindings.get(parent_external_id)
|
||
if not parent_binding or parent_binding.product_id == binding.product_id:
|
||
continue
|
||
if binding.product.parent_id != parent_binding.product_id:
|
||
binding.product.parent_id = parent_binding.product_id
|
||
binding.product.save(update_fields=["parent", "updated_at"])
|
||
updated += 1
|
||
return updated
|
||
|
||
|
||
@transaction.atomic
|
||
def normalize_moysklad_stock_row(
|
||
*, connection: IntegrationConnection, row: dict[str, Any], captured_at: datetime
|
||
) -> int:
|
||
source = connection.source
|
||
external_product_id = _external_id(row)
|
||
binding = ProductBinding.objects.filter(
|
||
source=source,
|
||
external_id=external_product_id,
|
||
).first()
|
||
if not binding:
|
||
return 0
|
||
created = 0
|
||
for store_stock in row.get("stockByStore") or []:
|
||
if not isinstance(store_stock, dict):
|
||
continue
|
||
warehouse_external_id = _reference_external_id(store_stock)
|
||
if not warehouse_external_id:
|
||
continue
|
||
StockSnapshot.objects.update_or_create(
|
||
product=binding.product,
|
||
source=source,
|
||
warehouse_external_id=warehouse_external_id,
|
||
captured_at=captured_at,
|
||
defaults={
|
||
"warehouse_name": str(store_stock.get("name") or "")[:255],
|
||
"quantity": _decimal(store_stock.get("stock")),
|
||
"reserved": _decimal(store_stock.get("reserve")),
|
||
"in_transit": _decimal(store_stock.get("inTransit")),
|
||
"metadata": {
|
||
"provider": "moysklad",
|
||
"external_product_id": external_product_id,
|
||
},
|
||
},
|
||
)
|
||
created += 1
|
||
return created
|
||
|
||
|
||
def _document_positions(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||
positions = row.get("positions") or []
|
||
if isinstance(positions, dict):
|
||
positions = positions.get("rows") or []
|
||
return [item for item in positions if isinstance(item, dict)]
|
||
|
||
|
||
def _assortment_external_id(position: dict[str, Any]) -> str:
|
||
assortment = position.get("assortment") or {}
|
||
if not isinstance(assortment, dict):
|
||
return ""
|
||
return _reference_external_id(assortment)
|
||
|
||
|
||
def _document_currency(row: dict[str, Any]) -> str:
|
||
rate = row.get("rate") if isinstance(row.get("rate"), dict) else {}
|
||
currency = (
|
||
rate.get("currency") if isinstance(rate.get("currency"), dict) else {}
|
||
)
|
||
value = str(
|
||
currency.get("isoCode")
|
||
or currency.get("code")
|
||
or currency.get("name")
|
||
or "RUB"
|
||
).upper()
|
||
return value[:3] if len(value) >= 3 else "RUB"
|
||
|
||
|
||
def _document_channel(
|
||
row: dict[str, Any], channel_names: dict[str, str] | None
|
||
) -> str:
|
||
sales_channel = (
|
||
row.get("salesChannel") if isinstance(row.get("salesChannel"), dict) else {}
|
||
)
|
||
name = str(sales_channel.get("name") or "").strip()
|
||
if not name and channel_names:
|
||
name = channel_names.get(_reference_external_id(sales_channel), "")
|
||
return (name or "Не указан")[:64]
|
||
|
||
|
||
def _position_cost(position: dict[str, Any], quantity: Decimal) -> tuple[Decimal, str]:
|
||
position_cost = _decimal(position.get("cost")) / Decimal("100")
|
||
if position_cost > 0:
|
||
return (
|
||
(quantity * position_cost).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
),
|
||
"position.cost",
|
||
)
|
||
assortment = (
|
||
position.get("assortment")
|
||
if isinstance(position.get("assortment"), dict)
|
||
else {}
|
||
)
|
||
buy_price = assortment.get("buyPrice")
|
||
unit_cost = (
|
||
_decimal(buy_price.get("value")) / Decimal("100")
|
||
if isinstance(buy_price, dict)
|
||
else Decimal("0")
|
||
)
|
||
return (quantity * unit_cost).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
), (
|
||
"assortment.buyPrice" if unit_cost > 0 else "unavailable"
|
||
)
|
||
|
||
|
||
def _historical_product_from_document(
|
||
*, source, assortment: dict[str, Any], external_id: str
|
||
) -> Product:
|
||
existing = ProductBinding.objects.select_related("product").filter(
|
||
source=source,
|
||
external_id=external_id,
|
||
).first()
|
||
if existing:
|
||
return existing.product
|
||
external_sku = str(
|
||
assortment.get("article") or assortment.get("code") or ""
|
||
).strip()
|
||
product = Product.objects.create(
|
||
sku=_candidate_sku(assortment, external_id),
|
||
name=str(assortment.get("name") or external_sku or external_id)[:255],
|
||
barcode=_barcode(assortment)[:64],
|
||
description=str(assortment.get("description") or ""),
|
||
kind=_product_kind(assortment),
|
||
is_active=False,
|
||
)
|
||
metadata = _binding_metadata(assortment)
|
||
metadata["historical_from_document"] = True
|
||
ProductBinding.objects.create(
|
||
product=product,
|
||
source=source,
|
||
external_id=external_id,
|
||
external_sku=external_sku[:128],
|
||
metadata=metadata,
|
||
is_primary=True,
|
||
)
|
||
return product
|
||
|
||
|
||
@transaction.atomic
|
||
def normalize_moysklad_document(
|
||
*,
|
||
connection: IntegrationConnection,
|
||
row: dict[str, Any],
|
||
document_type: str,
|
||
channel_names: dict[str, str] | None = None,
|
||
) -> int:
|
||
source = connection.source
|
||
source_id = _external_id(row)
|
||
if not source_id:
|
||
return 0
|
||
external_id = f"{document_type}:{source_id}"
|
||
event_type = (
|
||
SaleEventType.RETURN if document_type == "salesreturn" else SaleEventType.SALE
|
||
)
|
||
source_result = ingest_source_record(
|
||
source=source,
|
||
entity_type=document_type,
|
||
external_id=source_id,
|
||
payload=row,
|
||
source_updated_at=_source_updated_at(row),
|
||
)
|
||
occurred_at = _source_updated_at({"updated": row.get("moment")}) or timezone.now()
|
||
state = row.get("state") if isinstance(row.get("state"), dict) else {}
|
||
sale, _ = Sale.objects.update_or_create(
|
||
source=source,
|
||
external_id=external_id,
|
||
defaults={
|
||
"source_record": source_result.record,
|
||
"event_type": event_type,
|
||
"number": str(row.get("name") or "")[:128],
|
||
"channel": _document_channel(row, channel_names),
|
||
"status": str(state.get("name") or "")[:128],
|
||
"counts_as_sale": bool(row.get("applicable", True)),
|
||
"currency": _document_currency(row),
|
||
"occurred_at": occurred_at,
|
||
"source_updated_at": _source_updated_at(row),
|
||
},
|
||
)
|
||
retained_line_ids = []
|
||
for index, position in enumerate(_document_positions(row), start=1):
|
||
position_id = _external_id(position) or str(index)
|
||
product_external_id = _assortment_external_id(position)
|
||
assortment = position.get("assortment") or {}
|
||
if not isinstance(assortment, dict):
|
||
assortment = {}
|
||
external_sku = str(
|
||
assortment.get("article") or assortment.get("code") or ""
|
||
).strip()
|
||
barcode = _barcode(assortment)
|
||
match = match_product(
|
||
source=source,
|
||
external_id=product_external_id,
|
||
external_sku=external_sku,
|
||
barcode=barcode,
|
||
)
|
||
matched_product = match.product
|
||
if matched_product is None and product_external_id:
|
||
matched_product = _historical_product_from_document(
|
||
source=source,
|
||
assortment=assortment,
|
||
external_id=product_external_id,
|
||
)
|
||
quantity = _decimal(position.get("quantity"))
|
||
gross, discount, vat_amount = _position_revenue(row, position)
|
||
cost_amount, cost_source = _position_cost(position, quantity)
|
||
line, _ = SaleLine.objects.update_or_create(
|
||
sale=sale,
|
||
external_id=position_id,
|
||
defaults={
|
||
"product": matched_product,
|
||
"external_product_id": product_external_id[:255],
|
||
"external_sku": external_sku[:128],
|
||
"name": str(assortment.get("name") or "")[:255],
|
||
"quantity": quantity,
|
||
"gross_amount": gross,
|
||
"discount_amount": min(discount, gross),
|
||
"cost_amount": cost_amount,
|
||
"metadata": {
|
||
"provider": "moysklad",
|
||
"cost_source": cost_source,
|
||
"vat": position.get("vat"),
|
||
"vat_enabled": bool(position.get("vatEnabled")),
|
||
"vat_amount": str(vat_amount),
|
||
"overhead": str(_money(position.get("overhead"))),
|
||
},
|
||
},
|
||
)
|
||
retained_line_ids.append(line.pk)
|
||
sale.lines.exclude(pk__in=retained_line_ids).delete()
|
||
return len(retained_line_ids)
|
||
|
||
|
||
def sync_moysklad_connection(
|
||
connection: IntegrationConnection,
|
||
*,
|
||
progress_callback: Callable[[MoySkladSyncStats], None] | None = None,
|
||
) -> MoySkladSyncStats:
|
||
credentials = decrypt_credentials(connection)
|
||
stats = MoySkladSyncStats()
|
||
captured_at = timezone.now()
|
||
with MoySkladClient(
|
||
auth_type=connection.auth_type, credentials=credentials
|
||
) as client:
|
||
for row in client.iter_assortment():
|
||
created, matched, _has_stock = normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=captured_at,
|
||
)
|
||
stats.processed += 1
|
||
stats.created_products += int(created)
|
||
stats.matched_products += int(matched)
|
||
try:
|
||
stats.images += sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
)
|
||
except Exception as error: # noqa: BLE001 - one image must not abort facts
|
||
stats.image_errors += 1
|
||
logger.warning(
|
||
"MoySklad product images could not be synchronized",
|
||
extra={
|
||
"event": "commerce.product.images_sync_failed",
|
||
"connection_id": str(connection.pk),
|
||
"external_product_id": str(row.get("id") or ""),
|
||
"entity_type": str((row.get("meta") or {}).get("type") or ""),
|
||
"error_type": type(error).__name__,
|
||
"failure_reason": str(error)[:500],
|
||
},
|
||
)
|
||
if progress_callback and stats.processed % 25 == 0:
|
||
progress_callback(stats)
|
||
reconcile_moysklad_variant_parents(connection.source)
|
||
for stock_row in client.iter_stock_by_store():
|
||
stats.stock_snapshots += normalize_moysklad_stock_row(
|
||
connection=connection,
|
||
row=stock_row,
|
||
captured_at=captured_at,
|
||
)
|
||
StockSnapshot.objects.filter(
|
||
source=connection.source,
|
||
warehouse_external_id="all",
|
||
).delete()
|
||
channel_names = client.sales_channel_names()
|
||
for document_type in ("demand", "salesreturn"):
|
||
for row in client.iter_documents(document_type):
|
||
stats.sale_lines += normalize_moysklad_document(
|
||
connection=connection,
|
||
row=row,
|
||
document_type=document_type,
|
||
channel_names=channel_names,
|
||
)
|
||
stats.sales += 1
|
||
if progress_callback and stats.sales % 25 == 0:
|
||
progress_callback(stats)
|
||
if progress_callback:
|
||
progress_callback(stats)
|
||
return stats
|
||
|
||
|
||
def test_moysklad_connection(connection: IntegrationConnection) -> dict[str, str]:
|
||
credentials = decrypt_credentials(connection)
|
||
with MoySkladClient(
|
||
auth_type=connection.auth_type, credentials=credentials
|
||
) as client:
|
||
employee = client.employee_context()
|
||
return {
|
||
"account_id": str(employee.get("accountId") or ""),
|
||
"account_name": str(
|
||
employee.get("fullName")
|
||
or employee.get("name")
|
||
or employee.get("uid")
|
||
or ""
|
||
),
|
||
}
|