from typing import Any, Dict, List, Optional from core.sap import SAPServiceLayerClient from domain.documents import crear_documento_sap from domain.payment import transacciones_efectivas def _nombre_completo(persona: Dict[str, Any]) -> str: nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip() return nombre or persona.get("name", "") or "" def _precio_unitario_con_descuento(item: Dict[str, Any]) -> float: """ item["price"] es el precio unitario ANTES de descuento. El descuento real de la línea viene en discount_allocations (total_discount no siempre lo refleja). Se descuenta antes de armar PriceAfterVAT. """ cantidad = item.get("quantity") or 1 precio_total = float(item["price"]) * cantidad descuento = sum(float(d.get("amount", 0)) for d in item.get("discount_allocations", [])) return round((precio_total - descuento) / cantidad, 2) def _cantidad_cancelada(order: Dict[str, Any], line_item_id: Any) -> int: """ Unidades de una línea canceladas antes de la venta (refund_line_items con restock_type "cancel", ej. líneas eliminadas de la orden antes del pago). Las devoluciones posteriores a la entrega ("return") no se descuentan aquí. """ return sum( int(rli.get("quantity") or 0) for refund in order.get("refunds") or [] for rli in refund.get("refund_line_items") or [] if rli.get("line_item_id") == line_item_id and rli.get("restock_type") == "cancel" ) def _armar_lineas(order: Dict[str, Any], warehouse_code: str, cantidad_fn) -> List[Dict[str, Any]]: lineas = [] for item in order.get("line_items", []): cantidad = cantidad_fn(item) if item.get("sku") and cantidad > 0: lineas.append( { "ItemCode": item["sku"], "Quantity": cantidad, "WarehouseCode": warehouse_code, "PriceAfterVAT": _precio_unitario_con_descuento(item), "TaxCode": "IVA", } ) return lineas def _total(lineas: List[Dict[str, Any]]) -> float: return sum(l["Quantity"] * l["PriceAfterVAT"] for l in lineas) def _lineas_por_cobrado( order: Dict[str, Any], warehouse_code: str, transactions: Optional[List[Dict[str, Any]]] ) -> List[Dict[str, Any]]: """ La Invoice debe igualar lo realmente cobrado (transacciones efectivas). Se prueban dos juegos de líneas y se usa el que cuadre: 1. líneas originales (quantity): ventas normales y órdenes despachadas que se reembolsaron después de cobrar el total (#CL10072, #8029); 2. líneas vigentes (current_quantity): líneas canceladas o devueltas que nunca se cobraron (#7998, #CL10144). Si ninguno cuadra se devuelven las originales y el control de suma del pago deja la orden en error. Sin transacciones (API caída) se descuentan solo las canceladas antes de la venta (restock_type "cancel"). """ if transactions is None: return _armar_lineas(order, warehouse_code, lambda i: i["quantity"] - _cantidad_cancelada(order, i.get("id"))) originales = _armar_lineas(order, warehouse_code, lambda i: i["quantity"]) cobrado = sum(float(t["amount"]) for t in transacciones_efectivas(transactions)) if abs(_total(originales) - cobrado) < 1: return originales vigentes = _armar_lineas( order, warehouse_code, lambda i: i["current_quantity"] if i.get("current_quantity") is not None else i["quantity"] ) if vigentes and abs(_total(vigentes) - cobrado) < 1: return vigentes return originales def fulfillments_facturados(fulfillments: List[Dict[str, Any]], invoice: Dict[str, Any]) -> List[Dict[str, Any]]: """ Limita cada fulfillment a lo que quedó facturado: líneas devueltas o canceladas que no se cobraron (ver _lineas_por_cobrado) no están en la Invoice, y mover o entregar esas unidades rompería el Delivery (la línea base no existe) o dejaría stock transferido sin venta. Por cada SKU se permite como máximo la cantidad de la Invoice, repartida en orden entre los fulfillments; los que quedan sin líneas se omiten. """ permitido: Dict[str, float] = {} for line in invoice["DocumentLines"]: permitido[line["ItemCode"]] = permitido.get(line["ItemCode"], 0) + line["Quantity"] resultado = [] for fulfillment in fulfillments: lineas = [] for item in fulfillment.get("line_items", []): disponible = permitido.get(item.get("sku"), 0) if disponible <= 0: continue cantidad = min(item["quantity"], disponible) permitido[item["sku"]] = disponible - cantidad lineas.append({**item, "quantity": cantidad}) if lineas: resultado.append({**fulfillment, "line_items": lineas}) return resultado def _campos_direccion(prefijo: str, direccion: Dict[str, Any]) -> Dict[str, Any]: if not direccion: return {} campos = { f"{prefijo}Street": direccion.get("address1"), f"{prefijo}Block": direccion.get("address2"), f"{prefijo}Building": direccion.get("phone"), f"{prefijo}City": direccion.get("city"), f"{prefijo}ZipCode": direccion.get("zip"), f"{prefijo}County": direccion.get("province"), } if direccion.get("country_code") == "CL": campos[f"{prefijo}Country"] = "CL" return campos def construir_invoice( order: Dict[str, Any], store_config: Dict[str, Any], card_code: str, federal_tax_id: Optional[str] = None, transactions: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: """ Arma el JSON para POST Invoices (factura de reserva). Las líneas se arman por lo realmente cobrado (ver _lineas_por_cobrado). Sobrescribe CardName y direcciones con los datos reales del cliente de Shopify, aunque el CardCode sea el genérico. Si se conoce el RUT (federal_tax_id, en formato SAP "NNNNNNNN-D"), también sobrescribe FederalTaxID, para que el RUT real del cliente quede en la factura aunque se use el CardCode genérico. """ customer = order.get("customer") or {} billing = order.get("billing_address") or {} shipping = order.get("shipping_address") or {} card_name = _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify" doc_date = order["created_at"][:10] warehouse_code = store_config["sap"]["default_warehouse_invoice"] series = store_config["sap"]["numbering_series"]["INVOICE"] document_lines = _lineas_por_cobrado(order, warehouse_code, transactions) address_extension = { **_campos_direccion("BillTo", billing), **_campos_direccion("ShipTo", shipping), } payload = { "CardCode": card_code, "CardName": card_name, "DocDate": doc_date, "DocDueDate": doc_date, "Series": series, "DocType": "dDocument_Items", "DocCurrency": "CLP", "ReserveInvoice": "tYES", "NumAtCard": order.get("name"), "AddressExtension": address_extension, "U_ShopifyOrderId": order["id"], "U_ShopifyStore": store_config["store_code"], "DocumentLines": document_lines, } if federal_tax_id: payload["FederalTaxID"] = federal_tax_id return payload def crear_invoice(sap: SAPServiceLayerClient, payload: Dict[str, Any]) -> Dict[str, Any]: """POST Invoices, con diagnóstico de errores conocidos (ver domain/documents.py).""" item_codes = [line["ItemCode"] for line in payload["DocumentLines"]] return crear_documento_sap(sap, "Invoices", payload, item_codes=item_codes) def agregar_referencia_documento( sap: SAPServiceLayerClient, invoice_doc_entry: int, ref_doc_entry: int, ref_doc_num: int, ref_obj_type: str, ) -> None: """ PATCH a la Invoice para dejar registrada la referencia a un documento generado a partir de ella (ITR, StockTransfer, etc.). Lee las DocumentReferences actuales y agrega la nueva, sin pisar las que ya existan (una orden puede tener varios fulfillments -> varios documentos). """ actual = sap.forward("GET", f"Invoices({invoice_doc_entry})", params={"$select": "DocumentReferences"}) referencias = actual.json().get("DocumentReferences", []) referencias.append( { "RefDocEntr": ref_doc_entry, "RefDocNum": ref_doc_num, "RefObjType": ref_obj_type, } ) sap.forward("PATCH", f"Invoices({invoice_doc_entry})", json_data={"DocumentReferences": referencias})