Allow Kingdee token requests to be built from app key, app secret, configurable field names, optional timestamp fields, and extra JSON. Keep raw KINGDEE_TOKEN_BODY_JSON as an advanced override with current-time placeholders. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
459 lines
18 KiB
Python
459 lines
18 KiB
Python
"""Kingdee sales-order API adapter with local token/config management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import time
|
|
import uuid
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from obsidian_wiki.config import parse_config_file
|
|
from obsidian_wiki.file_assets import clean_config_value
|
|
|
|
DEFAULT_SALORDER_API_URL = "https://beijida.kdgalaxy.com/kapi/v2/h5a3/sm/sm_salorder/querySalOrder"
|
|
DEFAULT_CONFIG_PATH = Path(".runtime") / "kingdee.env"
|
|
DEFAULT_TOKEN_CACHE_PATH = Path(".runtime") / "kingdee-token.json"
|
|
DEFAULT_PAGE_SIZE = 100
|
|
DEFAULT_MAX_PAGES = 100
|
|
TOKEN_SKEW_SECONDS = 60
|
|
|
|
|
|
Urlopen = Callable[[urllib.request.Request], Any]
|
|
|
|
|
|
KINGDEE_CONFIG_TEMPLATE = f"""# 金蝶销售订单 API 本地配置。
|
|
# 这个文件只保存在本机;.runtime/ 已被 git 忽略,请不要提交密钥或 token。
|
|
|
|
# 变量:KINGDEE_SALORDER_API_URL
|
|
# 用途:查询销售订单 API 地址。
|
|
# 是否必填:可选,默认已是当前项目使用的官方 querySalOrder 接口。
|
|
KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL}
|
|
|
|
# 变量:KINGDEE_ACCESS_TOKEN
|
|
# 用途:直接指定 access token,绕过自动登录/刷新流程。
|
|
# 是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。
|
|
KINGDEE_ACCESS_TOKEN=
|
|
|
|
# 变量:KINGDEE_TOKEN_URL
|
|
# 用途:获取 access token 的登录/token 接口地址。
|
|
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
|
|
KINGDEE_TOKEN_URL=
|
|
|
|
# 推荐填法:用下面这些拆分变量让程序自动拼 token JSON body。
|
|
# 如果你的 token 接口字段名不是 app_key/app_secret/timestamp,就改对应 FIELD。
|
|
|
|
# 变量:KINGDEE_APP_KEY
|
|
# 用途:金蝶 OpenAPI 应用的 app key/client id。
|
|
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
|
|
KINGDEE_APP_KEY=
|
|
|
|
# 变量:KINGDEE_APP_SECRET
|
|
# 用途:金蝶 OpenAPI 应用的 app secret/client secret。
|
|
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
|
|
KINGDEE_APP_SECRET=
|
|
|
|
# 变量:KINGDEE_TOKEN_APP_KEY_FIELD
|
|
# 用途:token JSON body 中 app key 对应的字段名。
|
|
# 是否必填:可选,默认 app_key;如果金蝶文档写 client_id,就改成 client_id。
|
|
KINGDEE_TOKEN_APP_KEY_FIELD=app_key
|
|
|
|
# 变量:KINGDEE_TOKEN_APP_SECRET_FIELD
|
|
# 用途:token JSON body 中 app secret 对应的字段名。
|
|
# 是否必填:可选,默认 app_secret;如果金蝶文档写 client_secret,就改成 client_secret。
|
|
KINGDEE_TOKEN_APP_SECRET_FIELD=app_secret
|
|
|
|
# 变量:KINGDEE_TOKEN_TIMESTAMP_FIELD
|
|
# 用途:token JSON body 中当前时间/时间戳对应的字段名;留空则不发送时间。
|
|
# 是否必填:仅当金蝶 token 文档要求当前时间时填写,例如 timestamp。
|
|
KINGDEE_TOKEN_TIMESTAMP_FIELD=
|
|
|
|
# 变量:KINGDEE_TOKEN_TIMESTAMP_FORMAT
|
|
# 用途:当前时间格式。可选值:epoch_ms、epoch_seconds、datetime_utc、iso_utc、date_utc。
|
|
# 是否必填:可选,默认 epoch_ms。
|
|
KINGDEE_TOKEN_TIMESTAMP_FORMAT=epoch_ms
|
|
|
|
# 变量:KINGDEE_TOKEN_BODY_EXTRA_JSON
|
|
# 用途:token JSON body 的额外固定字段,例如租户、账套等;会合并到自动拼出的 body。
|
|
# 是否必填:可选;没有额外字段时保持 {{}}。
|
|
KINGDEE_TOKEN_BODY_EXTRA_JSON={{}}
|
|
|
|
# 高级填法:如果 token body 结构特殊,可直接填写完整 JSON。
|
|
# 填了 KINGDEE_TOKEN_BODY_JSON 后,会覆盖上面的拆分变量。
|
|
# 支持占位符:{{now_ms}}、{{now_epoch}}、{{now_seconds}}、{{now_datetime_utc}}、{{now_iso_utc}}、{{today_utc}}。
|
|
# 变量:KINGDEE_TOKEN_BODY_JSON
|
|
# 用途:调用登录/token 接口时发送的完整 JSON 请求体。
|
|
# 是否必填:可选;只有不用拆分变量时才填写,必须是一行合法 JSON。
|
|
KINGDEE_TOKEN_BODY_JSON=
|
|
|
|
# 变量:KINGDEE_TOKEN_HEADERS_JSON
|
|
# 用途:调用登录/token 接口时额外发送的请求头。
|
|
# 是否必填:可选;没有额外 header 时保持 {{}}。
|
|
KINGDEE_TOKEN_HEADERS_JSON={{}}
|
|
|
|
# 变量:KINGDEE_TOKEN_FIELD
|
|
# 用途:从 token 接口返回 JSON 中读取 access token 的字段路径。
|
|
# 是否必填:可选,默认从 data.access_token 读取。
|
|
KINGDEE_TOKEN_FIELD=data.access_token
|
|
|
|
# 变量:KINGDEE_TOKEN_EXPIRES_IN_FIELD
|
|
# 用途:从 token 接口返回 JSON 中读取有效期秒数的字段路径。
|
|
# 是否必填:可选,默认从 data.expires_in 读取;读不到时按 3600 秒处理。
|
|
KINGDEE_TOKEN_EXPIRES_IN_FIELD=data.expires_in
|
|
|
|
# 变量:KINGDEE_REFRESH_TOKEN_FIELD
|
|
# 用途:从 token 接口返回 JSON 中读取 refresh token 的字段路径。
|
|
# 是否必填:可选;只有配置 refresh 流程时才有用。
|
|
KINGDEE_REFRESH_TOKEN_FIELD=data.refresh_token
|
|
|
|
# 变量:KINGDEE_REFRESH_URL
|
|
# 用途:refresh token 接口地址。
|
|
# 是否必填:可选;不填时 token 过期会重新调用 KINGDEE_TOKEN_URL。
|
|
KINGDEE_REFRESH_URL=
|
|
|
|
# 变量:KINGDEE_REFRESH_BODY_JSON
|
|
# 用途:调用 refresh 接口时发送的 JSON 请求体;可使用 {{refresh_token}} 占位符。
|
|
# 是否必填:可选;填写 KINGDEE_REFRESH_URL 时通常需要填写。
|
|
KINGDEE_REFRESH_BODY_JSON=
|
|
|
|
# 变量:KINGDEE_ACGW_IDENTITY
|
|
# 用途:销售订单 API 要求的 x-acgw-identity 请求头。
|
|
# 是否必填:必填;不填无法调用销售订单查询 API。
|
|
KINGDEE_ACGW_IDENTITY=
|
|
|
|
# 变量:KINGDEE_PAGE_SIZE
|
|
# 用途:销售订单 API 分页大小,对应请求体 pageSize。
|
|
# 是否必填:可选,默认 100。
|
|
KINGDEE_PAGE_SIZE={DEFAULT_PAGE_SIZE}
|
|
"""
|
|
|
|
|
|
def ensure_kingdee_config(config_path: str | Path | None = None) -> bool:
|
|
path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser()
|
|
if path.exists():
|
|
return False
|
|
_ensure_private_parent(path)
|
|
path.write_text(KINGDEE_CONFIG_TEMPLATE, encoding="utf-8")
|
|
path.chmod(0o600)
|
|
return True
|
|
|
|
|
|
def load_kingdee_config(config_path: str | Path | None = None) -> dict[str, str]:
|
|
path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser()
|
|
values = parse_config_file(path)
|
|
aliases = {
|
|
"KINGDEE_API_URL": "KINGDEE_SALORDER_API_URL",
|
|
"KINGDEE_API_TOKEN": "KINGDEE_ACCESS_TOKEN",
|
|
"KINGDEE_LOGIN_URL": "KINGDEE_TOKEN_URL",
|
|
"KINGDEE_LOGIN_BODY_JSON": "KINGDEE_TOKEN_BODY_JSON",
|
|
"KINGDEE_LOGIN_HEADERS_JSON": "KINGDEE_TOKEN_HEADERS_JSON",
|
|
}
|
|
for key, value in os.environ.items():
|
|
if key.startswith("KINGDEE_") and value:
|
|
values[key] = clean_config_value(value)
|
|
for old, new in aliases.items():
|
|
if values.get(old) and not values.get(new):
|
|
values[new] = values[old]
|
|
values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL)
|
|
values.setdefault("KINGDEE_TOKEN_HEADERS_JSON", "{}")
|
|
values.setdefault("KINGDEE_TOKEN_APP_KEY_FIELD", "app_key")
|
|
values.setdefault("KINGDEE_TOKEN_APP_SECRET_FIELD", "app_secret")
|
|
values.setdefault("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms")
|
|
values.setdefault("KINGDEE_TOKEN_BODY_EXTRA_JSON", "{}")
|
|
values.setdefault("KINGDEE_TOKEN_FIELD", "data.access_token")
|
|
values.setdefault("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")
|
|
values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")
|
|
values.setdefault("KINGDEE_PAGE_SIZE", str(DEFAULT_PAGE_SIZE))
|
|
return values
|
|
|
|
|
|
def token_cache_path_for_config(config_path: str | Path | None = None) -> Path:
|
|
if config_path is None:
|
|
return DEFAULT_TOKEN_CACHE_PATH
|
|
return Path(config_path).expanduser().with_name("kingdee-token.json")
|
|
|
|
|
|
def get_kingdee_access_token(
|
|
config: dict[str, str],
|
|
*,
|
|
cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH,
|
|
urlopen: Callable[..., Any] = urllib.request.urlopen,
|
|
now_epoch: float | None = None,
|
|
) -> str:
|
|
now = time.time() if now_epoch is None else float(now_epoch)
|
|
if config.get("KINGDEE_ACCESS_TOKEN"):
|
|
return config["KINGDEE_ACCESS_TOKEN"]
|
|
|
|
cache_path = Path(cache_path).expanduser()
|
|
cached = _read_token_cache(cache_path)
|
|
if cached.get("access_token") and float(cached.get("expires_at") or 0) > now + TOKEN_SKEW_SECONDS:
|
|
return str(cached["access_token"])
|
|
|
|
token_payload: dict[str, Any] | None = None
|
|
refresh_token = str(cached.get("refresh_token") or "")
|
|
if refresh_token and config.get("KINGDEE_REFRESH_URL"):
|
|
try:
|
|
token_payload = _request_token(
|
|
config["KINGDEE_REFRESH_URL"],
|
|
_render_json_template(
|
|
config.get("KINGDEE_REFRESH_BODY_JSON", ""),
|
|
_template_values(now, {"refresh_token": refresh_token}),
|
|
),
|
|
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
|
|
urlopen=urlopen,
|
|
)
|
|
except Exception:
|
|
token_payload = None
|
|
|
|
if token_payload is None:
|
|
token_url = config.get("KINGDEE_TOKEN_URL", "")
|
|
if not token_url:
|
|
raise RuntimeError("KINGDEE_TOKEN_URL is required when no cached KINGDEE_ACCESS_TOKEN is available")
|
|
token_payload = _request_token(
|
|
token_url,
|
|
_build_token_body_json(config, now),
|
|
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
|
|
urlopen=urlopen,
|
|
)
|
|
|
|
access_token = _extract_path(token_payload, config.get("KINGDEE_TOKEN_FIELD", "data.access_token"))
|
|
if not access_token:
|
|
raise RuntimeError("Kingdee token response did not contain an access token")
|
|
expires_in = _int_or_default(_extract_path(token_payload, config.get("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")), 3600)
|
|
refresh_token = _extract_path(token_payload, config.get("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")) or refresh_token
|
|
cache = {
|
|
"access_token": str(access_token),
|
|
"refresh_token": str(refresh_token or ""),
|
|
"expires_at": now + max(expires_in - TOKEN_SKEW_SECONDS, 1),
|
|
"obtained_at": now,
|
|
}
|
|
_write_private_json(cache_path, cache)
|
|
return str(access_token)
|
|
|
|
|
|
def fetch_kingdee_sales_orders(
|
|
config: dict[str, str],
|
|
*,
|
|
filters: dict[str, Any] | None = None,
|
|
token_cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH,
|
|
page_size: int | None = None,
|
|
max_pages: int = DEFAULT_MAX_PAGES,
|
|
urlopen: Callable[..., Any] = urllib.request.urlopen,
|
|
now_epoch: float | None = None,
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
access_token = get_kingdee_access_token(config, cache_path=token_cache_path, urlopen=urlopen, now_epoch=now_epoch)
|
|
identity = config.get("KINGDEE_ACGW_IDENTITY", "")
|
|
if not identity:
|
|
raise RuntimeError("KINGDEE_ACGW_IDENTITY is required for Kingdee sales order API")
|
|
api_url = config.get("KINGDEE_SALORDER_API_URL") or DEFAULT_SALORDER_API_URL
|
|
size = page_size or _int_or_default(config.get("KINGDEE_PAGE_SIZE"), DEFAULT_PAGE_SIZE)
|
|
rows: list[dict[str, Any]] = []
|
|
pages: list[dict[str, Any]] = []
|
|
total_count: int | None = None
|
|
safe_filters = filters or {}
|
|
|
|
for page_no in range(1, max_pages + 1):
|
|
body = {"data": safe_filters, "pageNo": page_no, "pageSize": size}
|
|
request = urllib.request.Request(
|
|
api_url,
|
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"accesstoken": access_token,
|
|
"x-acgw-identity": identity,
|
|
"Idempotency-Key": str(uuid.uuid4()),
|
|
},
|
|
method="POST",
|
|
)
|
|
with urlopen(request, timeout=60) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
if payload.get("status") is False:
|
|
raise RuntimeError(
|
|
f"Kingdee sales order API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip()
|
|
)
|
|
page_data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
|
page_rows = page_data.get("rows") if isinstance(page_data.get("rows"), list) else []
|
|
pages.append(payload)
|
|
rows.extend([row for row in page_rows if isinstance(row, dict)])
|
|
total = page_data.get("totalCount")
|
|
if isinstance(total, int) and total >= 0:
|
|
total_count = total
|
|
if not page_rows or len(page_rows) < size:
|
|
break
|
|
if total_count is not None and len(rows) >= total_count:
|
|
break
|
|
return rows, {"source": "kingdee-sales-order-api", "pages_fetched": len(pages), "rows": rows, "pages": pages}
|
|
|
|
|
|
def build_sales_order_filters(args: Any) -> dict[str, Any]:
|
|
fields = {
|
|
"start_bizdate": getattr(args, "start_bizdate", None),
|
|
"end_bizdate": getattr(args, "end_bizdate", None),
|
|
"start_createtime": getattr(args, "start_createtime", None),
|
|
"end_createtime": getattr(args, "end_createtime", None),
|
|
"start_modifytime": getattr(args, "start_modifytime", None) or getattr(args, "since", None),
|
|
"end_modifytime": getattr(args, "end_modifytime", None),
|
|
"start_auditdate": getattr(args, "start_auditdate", None),
|
|
"end_auditdate": getattr(args, "end_auditdate", None),
|
|
}
|
|
filters = {key: value for key, value in fields.items() if value}
|
|
array_fields = {
|
|
"id": getattr(args, "id", None),
|
|
"billno": getattr(args, "billno", None),
|
|
"billstatus": getattr(args, "billstatus", None),
|
|
"org_number": getattr(args, "org_number", None),
|
|
"customer_number": getattr(args, "customer_number", None),
|
|
"customer_createorg_number": getattr(args, "customer_createorg_number", None),
|
|
}
|
|
for key, value in array_fields.items():
|
|
values = _split_values(value)
|
|
if values:
|
|
filters[key] = values
|
|
return filters
|
|
|
|
|
|
def _request_token(
|
|
url: str,
|
|
body_json: str,
|
|
headers_json: str,
|
|
*,
|
|
urlopen: Callable[..., Any],
|
|
) -> dict[str, Any]:
|
|
body = json.loads(body_json or "{}")
|
|
headers = {"Content-Type": "application/json", **json.loads(headers_json or "{}")}
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
with urlopen(request, timeout=60) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
if isinstance(payload, dict) and payload.get("status") is False:
|
|
raise RuntimeError(f"Kingdee token API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip())
|
|
return payload
|
|
|
|
|
|
def _build_token_body_json(config: dict[str, str], now_epoch: float) -> str:
|
|
body_json = config.get("KINGDEE_TOKEN_BODY_JSON", "")
|
|
if body_json:
|
|
return _render_json_template(body_json, _template_values(now_epoch))
|
|
|
|
body: dict[str, Any] = {}
|
|
app_key = config.get("KINGDEE_APP_KEY", "")
|
|
app_secret = config.get("KINGDEE_APP_SECRET", "")
|
|
app_key_field = config.get("KINGDEE_TOKEN_APP_KEY_FIELD") or "app_key"
|
|
app_secret_field = config.get("KINGDEE_TOKEN_APP_SECRET_FIELD") or "app_secret"
|
|
if app_key:
|
|
body[app_key_field] = app_key
|
|
if app_secret:
|
|
body[app_secret_field] = app_secret
|
|
|
|
timestamp_field = config.get("KINGDEE_TOKEN_TIMESTAMP_FIELD", "")
|
|
if timestamp_field:
|
|
body[timestamp_field] = _format_timestamp(now_epoch, config.get("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms"))
|
|
|
|
extra = json.loads(config.get("KINGDEE_TOKEN_BODY_EXTRA_JSON", "{}") or "{}")
|
|
if not isinstance(extra, dict):
|
|
raise RuntimeError("KINGDEE_TOKEN_BODY_EXTRA_JSON must be a JSON object")
|
|
body.update(extra)
|
|
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _template_values(now_epoch: float, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
|
|
values = {
|
|
"now_ms": str(int(now_epoch * 1000)),
|
|
"now_epoch": str(int(now_epoch)),
|
|
"now_seconds": str(int(now_epoch)),
|
|
"now_datetime_utc": dt.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"now_iso_utc": dt.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
"today_utc": dt.strftime("%Y-%m-%d"),
|
|
}
|
|
values.update(extra or {})
|
|
return values
|
|
|
|
|
|
def _format_timestamp(now_epoch: float, timestamp_format: str) -> str | int:
|
|
normalized = (timestamp_format or "epoch_ms").strip().lower()
|
|
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
|
|
if normalized in {"epoch_ms", "milliseconds", "millis"}:
|
|
return int(now_epoch * 1000)
|
|
if normalized in {"epoch_seconds", "seconds", "unix"}:
|
|
return int(now_epoch)
|
|
if normalized == "datetime_utc":
|
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
if normalized == "iso_utc":
|
|
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
if normalized == "date_utc":
|
|
return dt.strftime("%Y-%m-%d")
|
|
raise RuntimeError(f"unsupported KINGDEE_TOKEN_TIMESTAMP_FORMAT: {timestamp_format}")
|
|
|
|
|
|
def _render_json_template(template: str, values: dict[str, str]) -> str:
|
|
if not template:
|
|
return "{}"
|
|
rendered = template
|
|
for key, value in values.items():
|
|
rendered = rendered.replace("{" + key + "}", value)
|
|
return rendered
|
|
|
|
|
|
def _extract_path(payload: Any, path: str | None) -> Any:
|
|
if not path:
|
|
return None
|
|
current = payload
|
|
for part in path.split("."):
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(part)
|
|
return current
|
|
|
|
|
|
def _read_token_cache(path: Path) -> dict[str, Any]:
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _write_private_json(path: Path, data: dict[str, Any]) -> None:
|
|
_ensure_private_parent(path)
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
path.chmod(0o600)
|
|
|
|
|
|
def _ensure_private_parent(path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
path.parent.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _int_or_default(value: Any, default: int) -> int:
|
|
try:
|
|
parsed = int(str(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return parsed if parsed > 0 else default
|
|
|
|
|
|
def _split_values(value: Any) -> list[str]:
|
|
if not value:
|
|
return []
|
|
if isinstance(value, (list, tuple)):
|
|
raw = []
|
|
for item in value:
|
|
raw.extend(str(item).split(","))
|
|
else:
|
|
raw = str(value).split(",")
|
|
return [item.strip() for item in raw if item.strip()]
|