g360-cli 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/package.json +16 -6
- package/py/pyproject.toml +4 -4
- package/py/requirements.txt +4 -0
- package/py/src/g360_core/__init__.py +67 -4
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/py/src/g360_core/batch_processor.py +120 -0
- package/py/src/g360_core/commercial_engine.py +305 -0
- package/py/src/g360_core/logger.py +40 -0
- package/py/src/g360_core/pipeline.py +578 -0
- package/py/src/g360_core/processor.py +634 -0
- package/py/src/g360_core/processor_segmentacion.py +859 -0
- package/py/src/g360_core/processor_sku.py +427 -0
- package/py/src/g360_core/scanner.py +218 -0
- package/py/src/g360_core/utils.py +435 -0
- package/src/cli.js +25 -2
- package/src/commands/ingest.js +193 -0
- package/src/commands/scan.js +102 -0
- package/src/commands/validate.js +126 -0
- package/src/lib/python_runner.js +89 -0
- package/py/src/g360_core/flet/__init__.py +0 -3
- package/py/src/g360_core/flet/ingestion_panel.py +0 -218
- package/py/src/g360_core/ingestion.py +0 -480
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Commercial Engine - Motor de lógica de negocio para clasificación documental.
|
|
3
|
+
|
|
4
|
+
Responsabilidades:
|
|
5
|
+
classify_base: Clasificación primaria (VENTA/DEVOLUCION/AJUSTE)
|
|
6
|
+
parse_referencia: Descomposición de REFERENCIA en tipo/serie/numero
|
|
7
|
+
build_invoice_index: Índice de facturas para cruce de referencias
|
|
8
|
+
resolve_document_relationships: Asignación de SUBTIPO_AJUSTE
|
|
9
|
+
calculate_prices: PRECIO_BASE, RECARGO_UNITARIO, PRECIO_EFECTIVO
|
|
10
|
+
|
|
11
|
+
Principio: toda regla de negocio vive aquí, no en processor.py ni pipeline.py.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .utils import NC_PREFIXES, ND_PREFIXES
|
|
19
|
+
from .logger import get_logger
|
|
20
|
+
|
|
21
|
+
log = get_logger("commercial_engine")
|
|
22
|
+
|
|
23
|
+
# ─── Constantes de clasificación ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
CAT_VENTA = "VENTA"
|
|
26
|
+
CAT_DEVOLUCION = "DEVOLUCION"
|
|
27
|
+
CAT_AJUSTE = "AJUSTE"
|
|
28
|
+
|
|
29
|
+
SUBTIPO_PRECIO_LINEA = "PRECIO_LINEA"
|
|
30
|
+
SUBTIPO_PRECIO_PARCIAL = "PRECIO_PARCIAL"
|
|
31
|
+
SUBTIPO_CARGO_FIJO = "CARGO_FIJO"
|
|
32
|
+
SUBTIPO_SIN_BASE = "SIN_BASE"
|
|
33
|
+
|
|
34
|
+
REF_PATTERN = re.compile(r"^([A-Z0-9]+)/(\d+)-(\d+)$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─── Helpers internos ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def _find_fae_col(df: pd.DataFrame) -> str:
|
|
40
|
+
"""Encuentra la columna CANTIDAD FAE (puede venir como CANTIDAD_FAE)."""
|
|
41
|
+
for col in df.columns:
|
|
42
|
+
norm = col.upper().replace(" ", "_").replace("-", "_")
|
|
43
|
+
if norm == "CANTIDAD_FAE":
|
|
44
|
+
return col
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_tpo_col(df: pd.DataFrame) -> str:
|
|
49
|
+
"""Encuentra la columna TPO_DOC (puede venir como TIPO_DOC)."""
|
|
50
|
+
for col in df.columns:
|
|
51
|
+
if "TPO_DOC" in col or col == "TIPO_DOC":
|
|
52
|
+
return col
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─── Paso 1: Parseo de REFERENCIA ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def parse_referencia(df: pd.DataFrame) -> pd.DataFrame:
|
|
59
|
+
"""
|
|
60
|
+
Extrae REF_TIPO, REF_SERIE y REF_NUMERO del campo REFERENCIA.
|
|
61
|
+
|
|
62
|
+
Formato esperado: "F01/204-56287"
|
|
63
|
+
Para filas sin referencia, se rellena con "S/R".
|
|
64
|
+
"""
|
|
65
|
+
ref_tipo = []
|
|
66
|
+
ref_serie = []
|
|
67
|
+
ref_numero = []
|
|
68
|
+
|
|
69
|
+
for val in df.get("REFERENCIA", pd.Series("", index=df.index)):
|
|
70
|
+
raw = str(val).strip().upper() if pd.notna(val) else ""
|
|
71
|
+
m = REF_PATTERN.match(raw)
|
|
72
|
+
if m:
|
|
73
|
+
ref_tipo.append(m.group(1))
|
|
74
|
+
ref_serie.append(m.group(2))
|
|
75
|
+
ref_numero.append(m.group(3))
|
|
76
|
+
else:
|
|
77
|
+
ref_tipo.append("S/R")
|
|
78
|
+
ref_serie.append("S/R")
|
|
79
|
+
ref_numero.append("S/R")
|
|
80
|
+
|
|
81
|
+
df["REF_TIPO"] = ref_tipo
|
|
82
|
+
df["REF_SERIE"] = ref_serie
|
|
83
|
+
df["REF_NUMERO"] = ref_numero
|
|
84
|
+
return df
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─── Paso 2: Clasificación primaria ──────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def classify_base(df: pd.DataFrame) -> pd.DataFrame:
|
|
90
|
+
"""
|
|
91
|
+
Clasificación primaria basada solo en la fila actual (sin mirar otras).
|
|
92
|
+
|
|
93
|
+
CATEGORIA_OP:
|
|
94
|
+
VENTA → Facturas, Boletas (F01, BDI, etc.)
|
|
95
|
+
DEVOLUCION → Notas de Crédito con movimiento físico (CANTIDAD != 0)
|
|
96
|
+
AJUSTE → NC/ND sin movimiento físico (CANTIDAD == 0)
|
|
97
|
+
|
|
98
|
+
SUBTIPO_AJUSTE se inicializa vacío (se asigna en resolve_document_relationships).
|
|
99
|
+
"""
|
|
100
|
+
tpo_col = _find_tpo_col(df)
|
|
101
|
+
if tpo_col is None:
|
|
102
|
+
df["CATEGORIA_OP"] = CAT_VENTA
|
|
103
|
+
df["SUBTIPO_AJUSTE"] = ""
|
|
104
|
+
return df
|
|
105
|
+
|
|
106
|
+
tpo = df[tpo_col].astype(str).str.upper().str.strip()
|
|
107
|
+
cant = pd.to_numeric(df.get("CANTIDAD", 0), errors="coerce").fillna(0)
|
|
108
|
+
|
|
109
|
+
es_venta = tpo.isin(["F01", "BDI", "F03", "B01", "B03", "F07", "F08", "B07", "B08"])
|
|
110
|
+
nc_pattern = "|".join([f"^{p}" for p in NC_PREFIXES])
|
|
111
|
+
es_nc = tpo.str.contains(nc_pattern, na=False)
|
|
112
|
+
nd_pattern = "|".join([f"^{p}" for p in ND_PREFIXES])
|
|
113
|
+
es_nd = tpo.str.contains(nd_pattern, na=False)
|
|
114
|
+
|
|
115
|
+
condiciones = [
|
|
116
|
+
es_venta,
|
|
117
|
+
es_nc & (cant != 0),
|
|
118
|
+
es_nc & (cant == 0),
|
|
119
|
+
es_nd,
|
|
120
|
+
]
|
|
121
|
+
opciones = [
|
|
122
|
+
CAT_VENTA,
|
|
123
|
+
CAT_DEVOLUCION,
|
|
124
|
+
CAT_AJUSTE,
|
|
125
|
+
CAT_AJUSTE,
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
df["CATEGORIA_OP"] = np.select(condiciones, opciones, default=CAT_AJUSTE)
|
|
129
|
+
df["SUBTIPO_AJUSTE"] = ""
|
|
130
|
+
|
|
131
|
+
return df
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ─── Paso 3: Resolución de relaciones documentales ──────────────────────────
|
|
135
|
+
|
|
136
|
+
def _normalize_doc_id(val) -> str:
|
|
137
|
+
"""Normaliza ID de documento: quita '.0' de floats, espacios, mayúsculas."""
|
|
138
|
+
s = str(val).strip().upper()
|
|
139
|
+
if s.endswith(".0"):
|
|
140
|
+
s = s[:-2]
|
|
141
|
+
return s
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_invoice_index(df: pd.DataFrame) -> dict:
|
|
145
|
+
"""
|
|
146
|
+
Construye índice de facturas para resolver referencias.
|
|
147
|
+
|
|
148
|
+
Clave: (TPO_DOC, SERIE_DOC, NRO_DOC, ID_ARTICULO)
|
|
149
|
+
Valor: dict con CANTIDAD, SOLES, CANTIDAD_FAE agregados.
|
|
150
|
+
|
|
151
|
+
Solo indexa registros VENTA.
|
|
152
|
+
Retorna dict vacío si no hay facturas en el dataset.
|
|
153
|
+
"""
|
|
154
|
+
facturas = df[df["CATEGORIA_OP"] == CAT_VENTA].copy()
|
|
155
|
+
if facturas.empty:
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
for col in ["TPO_DOC", "SERIE_DOC", "NRO_DOC"]:
|
|
159
|
+
if col not in facturas.columns:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
fae_col = _find_fae_col(facturas) or "CANTIDAD"
|
|
163
|
+
if fae_col not in facturas.columns:
|
|
164
|
+
facturas[fae_col] = 0
|
|
165
|
+
|
|
166
|
+
# Normalizar IDs de documento y armar clave
|
|
167
|
+
facturas["_IDX_TIPO"] = facturas["TPO_DOC"].astype(str).str.upper().str.strip()
|
|
168
|
+
facturas["_IDX_SERIE"] = facturas["SERIE_DOC"].apply(_normalize_doc_id)
|
|
169
|
+
facturas["_IDX_NRO"] = facturas["NRO_DOC"].apply(_normalize_doc_id)
|
|
170
|
+
facturas["_IDX_SKU"] = facturas["ID_ARTICULO"].astype(str).str.strip() if "ID_ARTICULO" in facturas.columns else ""
|
|
171
|
+
|
|
172
|
+
# Convertir a numérico
|
|
173
|
+
facturas[fae_col] = pd.to_numeric(facturas[fae_col], errors="coerce").fillna(0)
|
|
174
|
+
facturas["SOLES"] = pd.to_numeric(facturas["SOLES"], errors="coerce").fillna(0)
|
|
175
|
+
facturas["CANTIDAD"] = pd.to_numeric(facturas["CANTIDAD"], errors="coerce").fillna(0)
|
|
176
|
+
|
|
177
|
+
idx_cols = ["_IDX_TIPO", "_IDX_SERIE", "_IDX_NRO", "_IDX_SKU"]
|
|
178
|
+
agg_cols = {"CANTIDAD": "sum", "SOLES": "sum", fae_col: "sum"}
|
|
179
|
+
|
|
180
|
+
grouped = facturas.groupby(idx_cols).agg(agg_cols).to_dict("index")
|
|
181
|
+
# Limpiar columnas auxiliares
|
|
182
|
+
facturas.drop(columns=["_IDX_TIPO", "_IDX_SERIE", "_IDX_NRO", "_IDX_SKU"], inplace=True)
|
|
183
|
+
return grouped
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_document_relationships(df: pd.DataFrame) -> pd.DataFrame:
|
|
187
|
+
"""
|
|
188
|
+
Resuelve referencias entre documentos.
|
|
189
|
+
|
|
190
|
+
Para registros AJUSTE, cruza REFERENCIA contra el índice de facturas
|
|
191
|
+
y asigna SUBTIPO_AJUSTE:
|
|
192
|
+
PRECIO_LINEA → misma factura + mismo SKU + FAE = CANTIDAD
|
|
193
|
+
PRECIO_PARCIAL → misma factura + mismo SKU + FAE < CANTIDAD
|
|
194
|
+
CARGO_FIJO → CANTIDAD_FAE = 1 (cargo fijo, no proporcional)
|
|
195
|
+
SIN_BASE → factura referenciada no encontrada
|
|
196
|
+
"""
|
|
197
|
+
ajustes = df[df["CATEGORIA_OP"] == CAT_AJUSTE]
|
|
198
|
+
if ajustes.empty:
|
|
199
|
+
df["SUBTIPO_AJUSTE"] = ""
|
|
200
|
+
return df
|
|
201
|
+
|
|
202
|
+
for col in ["REF_TIPO", "REF_SERIE", "REF_NUMERO"]:
|
|
203
|
+
if col not in df.columns:
|
|
204
|
+
df[col] = ""
|
|
205
|
+
|
|
206
|
+
idx = build_invoice_index(df)
|
|
207
|
+
fae_col = _find_fae_col(df) or "CANTIDAD"
|
|
208
|
+
|
|
209
|
+
if not idx:
|
|
210
|
+
df.loc[df["CATEGORIA_OP"] == CAT_AJUSTE, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
|
|
211
|
+
return df
|
|
212
|
+
|
|
213
|
+
for idx_row in df[df["CATEGORIA_OP"] == CAT_AJUSTE].index:
|
|
214
|
+
ref_tipo = str(df.at[idx_row, "REF_TIPO"]).strip().upper()
|
|
215
|
+
ref_serie = str(df.at[idx_row, "REF_SERIE"]).strip()
|
|
216
|
+
ref_numero = str(df.at[idx_row, "REF_NUMERO"]).strip()
|
|
217
|
+
sku = str(df.at[idx_row, "ID_ARTICULO"]).strip() if "ID_ARTICULO" in df.columns else ""
|
|
218
|
+
|
|
219
|
+
key_con_sku = (ref_tipo, ref_serie, ref_numero, sku)
|
|
220
|
+
key_sin_sku = (ref_tipo, ref_serie, ref_numero, "")
|
|
221
|
+
|
|
222
|
+
if key_con_sku in idx:
|
|
223
|
+
factura = idx[key_con_sku]
|
|
224
|
+
cant_fae_ajuste = abs(
|
|
225
|
+
pd.to_numeric(df.at[idx_row, fae_col], errors="coerce") or 0
|
|
226
|
+
)
|
|
227
|
+
cant_factura = abs(factura.get("CANTIDAD", 0))
|
|
228
|
+
|
|
229
|
+
if cant_fae_ajuste == 0:
|
|
230
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_CARGO_FIJO
|
|
231
|
+
elif abs(cant_fae_ajuste - cant_factura) < 0.01:
|
|
232
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_PRECIO_LINEA
|
|
233
|
+
elif cant_fae_ajuste < cant_factura:
|
|
234
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_PRECIO_PARCIAL
|
|
235
|
+
else:
|
|
236
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
|
|
237
|
+
else:
|
|
238
|
+
cant_fae_val = abs(
|
|
239
|
+
pd.to_numeric(df.at[idx_row, fae_col], errors="coerce") or 0
|
|
240
|
+
)
|
|
241
|
+
if cant_fae_val == 1:
|
|
242
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_CARGO_FIJO
|
|
243
|
+
else:
|
|
244
|
+
df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
|
|
245
|
+
|
|
246
|
+
return df
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ─── Paso 4: Cálculo de precios ─────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def calculate_prices(df: pd.DataFrame) -> pd.DataFrame:
|
|
252
|
+
"""
|
|
253
|
+
Calcula columnas de precio:
|
|
254
|
+
|
|
255
|
+
PRECIO_BASE → SOLES / CANTIDAD (solo cuando CANTIDAD != 0)
|
|
256
|
+
RECARGO_UNITARIO → SOLES / abs(CANTIDAD_FAE) (solo ajustes PRECIO_LINEA)
|
|
257
|
+
PRECIO_EFECTIVO → PRECIO_BASE + RECARGO_UNITARIO (ventas con ajuste linkeado)
|
|
258
|
+
"""
|
|
259
|
+
fae_col = _find_fae_col(df)
|
|
260
|
+
|
|
261
|
+
# PRECIO_BASE: solo para movimientos físicos
|
|
262
|
+
if "CANTIDAD" in df.columns and "SOLES" in df.columns:
|
|
263
|
+
cant = pd.to_numeric(df["CANTIDAD"], errors="coerce").fillna(0)
|
|
264
|
+
soles = pd.to_numeric(df["SOLES"], errors="coerce").fillna(0)
|
|
265
|
+
mask_fisica = cant != 0
|
|
266
|
+
df["PRECIO_BASE"] = np.where(
|
|
267
|
+
mask_fisica,
|
|
268
|
+
np.round(soles.abs() / cant.abs(), 4),
|
|
269
|
+
np.nan
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
df["PRECIO_BASE"] = np.nan
|
|
273
|
+
|
|
274
|
+
# RECARGO_UNITARIO: solo para ajustes PRECIO_LINEA o PRECIO_PARCIAL
|
|
275
|
+
if fae_col and "SOLES" in df.columns:
|
|
276
|
+
cant_fae = pd.to_numeric(df[fae_col], errors="coerce").fillna(0)
|
|
277
|
+
soles = pd.to_numeric(df["SOLES"], errors="coerce").fillna(0)
|
|
278
|
+
|
|
279
|
+
subtipo = df.get("SUBTIPO_AJUSTE", pd.Series(""))
|
|
280
|
+
es_ajuste_precio = subtipo.isin([SUBTIPO_PRECIO_LINEA, SUBTIPO_PRECIO_PARCIAL])
|
|
281
|
+
mask_recargo = es_ajuste_precio & (cant_fae != 0)
|
|
282
|
+
|
|
283
|
+
df["RECARGO_UNITARIO"] = np.where(
|
|
284
|
+
mask_recargo,
|
|
285
|
+
np.round(soles / cant_fae.abs(), 4),
|
|
286
|
+
np.nan
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
df["RECARGO_UNITARIO"] = np.nan
|
|
290
|
+
|
|
291
|
+
# PRECIO_EFECTIVO: base + recargo
|
|
292
|
+
# Para ventas: PRECIO_EFECTIVO = PRECIO_BASE
|
|
293
|
+
# Para ajustes linkeados: no se suma directamente (se propaga a nivel de agregación)
|
|
294
|
+
if "PRECIO_BASE" in df.columns:
|
|
295
|
+
base_val = df["PRECIO_BASE"].fillna(0)
|
|
296
|
+
recargo_val = df.get("RECARGO_UNITARIO", pd.Series(0)).fillna(0)
|
|
297
|
+
df["PRECIO_EFECTIVO"] = np.where(
|
|
298
|
+
df["PRECIO_BASE"].notna(),
|
|
299
|
+
np.round(base_val + recargo_val, 4),
|
|
300
|
+
np.nan
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
df["PRECIO_EFECTIVO"] = np.nan
|
|
304
|
+
|
|
305
|
+
return df
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Structured logging for G360 Insight Lens."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
|
8
|
+
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
9
|
+
|
|
10
|
+
_configured = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logging(level: int = logging.INFO, log_file: str = None):
|
|
14
|
+
"""Configure root logger with console + optional file handler."""
|
|
15
|
+
global _configured
|
|
16
|
+
if _configured:
|
|
17
|
+
return
|
|
18
|
+
_configured = True
|
|
19
|
+
|
|
20
|
+
root = logging.getLogger("g360")
|
|
21
|
+
root.setLevel(level)
|
|
22
|
+
|
|
23
|
+
console = logging.StreamHandler(sys.stdout)
|
|
24
|
+
console.setLevel(level)
|
|
25
|
+
console.setFormatter(logging.Formatter(_LOG_FORMAT, _DATE_FORMAT))
|
|
26
|
+
root.addHandler(console)
|
|
27
|
+
|
|
28
|
+
if log_file:
|
|
29
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
31
|
+
file_handler.setLevel(level)
|
|
32
|
+
file_handler.setFormatter(logging.Formatter(_LOG_FORMAT, _DATE_FORMAT))
|
|
33
|
+
root.addHandler(file_handler)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_logger(name: str) -> logging.Logger:
|
|
37
|
+
"""Get a child logger under the 'g360' namespace."""
|
|
38
|
+
if not _configured:
|
|
39
|
+
setup_logging()
|
|
40
|
+
return logging.getLogger(f"g360.{name}")
|