Skip to main content

nfe_web/graphql/
resolvers.rs

1//! Resolvers GraphQL com integração SEFAZ
2
3use super::types::*;
4use crate::sefaz;
5use crate::sefaz::webservice::{SefazClient, AmbienteNfe};
6use crate::certificado::{CertificadoA1, AssinadorXml};
7use async_graphql::{Context, Object, Result as GqlResult};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11/// Estado compartilhado do GraphQL
12pub struct GraphQLState {
13    pub certificado: Option<CertificadoA1>,
14    pub sefaz_client: Option<SefazClient>,
15}
16
17impl Default for GraphQLState {
18    fn default() -> Self {
19        Self {
20            certificado: None,
21            sefaz_client: None,
22        }
23    }
24}
25
26/// Query root
27pub struct QueryRoot;
28
29#[Object]
30impl QueryRoot {
31    /// Consulta NF-e por chave de acesso
32    async fn nfe(&self, chave_acesso: String) -> GqlResult<Option<NfeType>> {
33        // Validar chave
34        let info = sefaz::validar_chave_acesso(&chave_acesso)
35            .map_err(|e| async_graphql::Error::new(e))?;
36
37        // Retornar dados básicos da chave
38        Ok(Some(NfeType {
39            id: chave_acesso.clone(),
40            chave_acesso: chave_acesso.clone(),
41            numero: info.numero as i32,
42            serie: info.serie as i32,
43            tipo: TipoDocumento::Nfe,
44            ambiente: Ambiente::Producao,
45            status: StatusNfe::Pendente,
46            data_emissao: format!("20{}-{}-01", &info.ano_mes[0..2], &info.ano_mes[2..4]),
47            data_autorizacao: None,
48            protocolo: None,
49            emitente: EmitenteType {
50                cnpj: info.cnpj,
51                razao_social: "Consulte SEFAZ para dados completos".to_string(),
52                nome_fantasia: None,
53                inscricao_estadual: None,
54                endereco: EnderecoType {
55                    logradouro: String::new(),
56                    numero: String::new(),
57                    complemento: None,
58                    bairro: String::new(),
59                    municipio: String::new(),
60                    uf: info.uf,
61                    cep: String::new(),
62                    pais: Some("Brasil".to_string()),
63                },
64            },
65            destinatario: None,
66            itens: vec![],
67            totais: TotaisType {
68                base_calculo_icms: 0.0,
69                valor_icms: 0.0,
70                valor_produtos: 0.0,
71                valor_frete: 0.0,
72                valor_desconto: 0.0,
73                valor_total: 0.0,
74            },
75            xml: None,
76        }))
77    }
78
79    /// Lista NF-e com filtros
80    async fn nfes(
81        &self,
82        filter: Option<NfeFilter>,
83        pagination: Option<Pagination>,
84    ) -> GqlResult<Vec<NfeType>> {
85        let _filter = filter.unwrap_or_default();
86        let _pagination = pagination.unwrap_or_default();
87
88        // Em produção, buscar do banco de dados
89        Ok(vec![])
90    }
91
92    /// Consulta status no SEFAZ (usa WebService se certificado disponível)
93    async fn consultar_sefaz(&self, ctx: &Context<'_>, chave_acesso: String) -> GqlResult<ConsultaSefazResult> {
94        let info = sefaz::validar_chave_acesso(&chave_acesso)
95            .map_err(|e| async_graphql::Error::new(e))?;
96
97        // Tentar usar cliente SEFAZ se disponível
98        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
99            let state = state.read().await;
100            if let Some(ref client) = state.sefaz_client {
101                match client.consultar_nfe(&chave_acesso).await {
102                    Ok(resultado) => {
103                        return Ok(ConsultaSefazResult {
104                            sucesso: resultado.sucesso,
105                            codigo_status: resultado.codigo_status.unwrap_or_else(|| "000".to_string()),
106                            motivo: resultado.motivo.unwrap_or_else(|| "Consulta realizada".to_string()),
107                            chave_acesso: resultado.chave_acesso,
108                            protocolo: resultado.protocolo,
109                            data_recebimento: resultado.data_autorizacao,
110                            situacao: resultado.situacao,
111                        });
112                    }
113                    Err(e) => {
114                        return Err(async_graphql::Error::new(format!("Erro SEFAZ: {}", e)));
115                    }
116                }
117            }
118        }
119
120        // Fallback: retornar URL do portal público
121        let url = sefaz::gerar_url_consulta_portal(&chave_acesso);
122
123        Ok(ConsultaSefazResult {
124            sucesso: true,
125            codigo_status: "100".to_string(),
126            motivo: "Chave válida - use certificado para consulta completa".to_string(),
127            chave_acesso: Some(info.chave),
128            protocolo: None,
129            data_recebimento: None,
130            situacao: Some(format!("Portal: {}", url)),
131        })
132    }
133
134    /// Consulta status do serviço SEFAZ
135    async fn status_servico(&self, ctx: &Context<'_>) -> GqlResult<StatusServicoType> {
136        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
137            let state = state.read().await;
138            if let Some(ref client) = state.sefaz_client {
139                match client.status_servico().await {
140                    Ok(status) => {
141                        return Ok(StatusServicoType {
142                            codigo_status: status.codigo_status.clone(),
143                            motivo: status.motivo,
144                            tempo_medio: status.tempo_medio.map(|t| format!("{} ms", t)),
145                            uf: None,
146                            online: status.online,
147                        });
148                    }
149                    Err(e) => {
150                        return Err(async_graphql::Error::new(format!("Erro SEFAZ: {}", e)));
151                    }
152                }
153            }
154        }
155
156        Err(async_graphql::Error::new("Certificado digital não configurado"))
157    }
158
159    /// Valida chave de acesso
160    async fn validar_chave(&self, chave: String) -> GqlResult<bool> {
161        match sefaz::validar_chave_acesso(&chave) {
162            Ok(_) => Ok(true),
163            Err(_) => Ok(false),
164        }
165    }
166
167    /// Informações do certificado carregado
168    async fn certificado(&self, ctx: &Context<'_>) -> GqlResult<Option<CertificadoInfoType>> {
169        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
170            let state = state.read().await;
171            if let Some(ref cert) = state.certificado {
172                return Ok(Some(CertificadoInfoType {
173                    cnpj: cert.info.cnpj.clone(),
174                    razao_social: cert.info.razao_social.clone(),
175                    valido: cert.info.valido,
176                    data_validade: cert.info.not_after.clone(),
177                    dias_para_expirar: cert.info.dias_para_expirar as i32,
178                }));
179            }
180        }
181
182        // Fallback para contexto antigo
183        if let Some(cert_info) = ctx.data_opt::<crate::certificado::CertificadoInfo>() {
184            Ok(Some(CertificadoInfoType {
185                cnpj: cert_info.cnpj.clone(),
186                razao_social: cert_info.razao_social.clone(),
187                valido: cert_info.valido,
188                data_validade: cert_info.not_after.clone(),
189                dias_para_expirar: cert_info.dias_para_expirar as i32,
190            }))
191        } else {
192            Ok(None)
193        }
194    }
195
196    /// Health check
197    async fn health(&self) -> GqlResult<String> {
198        Ok("OK".to_string())
199    }
200}
201
202/// Status do serviço SEFAZ
203#[derive(Debug, Clone, async_graphql::SimpleObject)]
204pub struct StatusServicoType {
205    pub codigo_status: String,
206    pub motivo: String,
207    pub tempo_medio: Option<String>,
208    pub uf: Option<String>,
209    pub online: bool,
210}
211
212/// Mutation root
213pub struct MutationRoot;
214
215#[Object]
216impl MutationRoot {
217    /// Emite NF-e no SEFAZ
218    async fn emitir_nfe(&self, ctx: &Context<'_>, input: NfeInput) -> GqlResult<EmissaoResult> {
219        // Validar dados
220        if input.itens.is_empty() {
221            return Err(async_graphql::Error::new("NF-e deve ter pelo menos um item"));
222        }
223
224        // Verificar se certificado está configurado
225        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
226            let state = state.read().await;
227
228            if let (Some(ref cert), Some(ref client)) = (&state.certificado, &state.sefaz_client) {
229                // Gerar XML da NF-e
230                let xml_nfe = gerar_xml_nfe(&input)?;
231
232                // Assinar XML
233                let assinador = AssinadorXml::new(cert.clone());
234                let xml_assinado = assinador.assinar_nfe(&xml_nfe)
235                    .map_err(|e| async_graphql::Error::new(format!("Erro ao assinar: {}", e)))?;
236
237                // Enviar para SEFAZ
238                match client.autorizar_nfe(&xml_assinado).await {
239                    Ok(resultado) => {
240                        return Ok(EmissaoResult {
241                            sucesso: resultado.sucesso,
242                            codigo_status: resultado.codigo_status,
243                            motivo: resultado.motivo,
244                            chave_acesso: resultado.chave_acesso,
245                            protocolo: resultado.protocolo,
246                            xml_autorizado: resultado.xml_autorizado,
247                        });
248                    }
249                    Err(e) => {
250                        return Err(async_graphql::Error::new(format!("Erro SEFAZ: {}", e)));
251                    }
252                }
253            }
254        }
255
256        Ok(EmissaoResult {
257            sucesso: false,
258            codigo_status: "999".to_string(),
259            motivo: "Certificado digital não configurado. Use carregar_certificado primeiro.".to_string(),
260            chave_acesso: None,
261            protocolo: None,
262            xml_autorizado: None,
263        })
264    }
265
266    /// Cancela NF-e no SEFAZ
267    async fn cancelar_nfe(&self, ctx: &Context<'_>, input: CancelamentoInput) -> GqlResult<CancelamentoResult> {
268        // Validar chave
269        let _info = sefaz::validar_chave_acesso(&input.chave_acesso)
270            .map_err(|e| async_graphql::Error::new(e))?;
271
272        // Validar justificativa (mínimo 15 caracteres)
273        if input.justificativa.len() < 15 {
274            return Err(async_graphql::Error::new("Justificativa deve ter no mínimo 15 caracteres"));
275        }
276
277        // Verificar se certificado está configurado
278        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
279            let state = state.read().await;
280
281            if let Some(ref client) = state.sefaz_client {
282                match client.cancelar_nfe(&input.chave_acesso, &input.protocolo_autorizacao, &input.justificativa).await {
283                    Ok(resultado) => {
284                        return Ok(CancelamentoResult {
285                            sucesso: resultado.sucesso,
286                            codigo_status: resultado.codigo_status,
287                            motivo: resultado.motivo,
288                            protocolo: resultado.protocolo,
289                            data_cancelamento: resultado.data_evento,
290                        });
291                    }
292                    Err(e) => {
293                        return Err(async_graphql::Error::new(format!("Erro SEFAZ: {}", e)));
294                    }
295                }
296            }
297        }
298
299        Ok(CancelamentoResult {
300            sucesso: false,
301            codigo_status: "999".to_string(),
302            motivo: "Certificado digital não configurado. Use carregar_certificado primeiro.".to_string(),
303            protocolo: None,
304            data_cancelamento: None,
305        })
306    }
307
308    /// Envia carta de correção
309    async fn carta_correcao(&self, ctx: &Context<'_>, input: CartaCorrecaoInput) -> GqlResult<EmissaoResult> {
310        // Validar chave
311        let _info = sefaz::validar_chave_acesso(&input.chave_acesso)
312            .map_err(|e| async_graphql::Error::new(e))?;
313
314        // Validar correção (mínimo 15 caracteres)
315        if input.correcao.len() < 15 {
316            return Err(async_graphql::Error::new("Correção deve ter no mínimo 15 caracteres"));
317        }
318
319        // Verificar se certificado está configurado
320        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
321            let state = state.read().await;
322
323            if let Some(ref client) = state.sefaz_client {
324                match client.carta_correcao(&input.chave_acesso, input.sequencia as u32, &input.correcao).await {
325                    Ok(resultado) => {
326                        return Ok(EmissaoResult {
327                            sucesso: resultado.sucesso,
328                            codigo_status: resultado.codigo_status,
329                            motivo: resultado.motivo,
330                            chave_acesso: Some(input.chave_acesso),
331                            protocolo: resultado.protocolo,
332                            xml_autorizado: None,
333                        });
334                    }
335                    Err(e) => {
336                        return Err(async_graphql::Error::new(format!("Erro SEFAZ: {}", e)));
337                    }
338                }
339            }
340        }
341
342        Ok(EmissaoResult {
343            sucesso: false,
344            codigo_status: "999".to_string(),
345            motivo: "Certificado digital não configurado. Use carregar_certificado primeiro.".to_string(),
346            chave_acesso: None,
347            protocolo: None,
348            xml_autorizado: None,
349        })
350    }
351
352    /// Carrega certificado digital e inicializa cliente SEFAZ
353    async fn carregar_certificado(
354        &self,
355        ctx: &Context<'_>,
356        pfx_base64: String,
357        senha: String,
358        uf: String,
359        ambiente: Option<String>,
360    ) -> GqlResult<CertificadoInfoType> {
361        use base64::Engine;
362
363        let pfx_bytes = base64::engine::general_purpose::STANDARD
364            .decode(&pfx_base64)
365            .map_err(|e| async_graphql::Error::new(format!("Erro ao decodificar certificado: {}", e)))?;
366
367        let cert = CertificadoA1::from_bytes(&pfx_bytes, &senha)
368            .map_err(|e| async_graphql::Error::new(e))?;
369
370        // Verificar validade
371        if !cert.is_valid() {
372            return Err(async_graphql::Error::new("Certificado expirado ou inválido"));
373        }
374
375        let info = CertificadoInfoType {
376            cnpj: cert.info.cnpj.clone(),
377            razao_social: cert.info.razao_social.clone(),
378            valido: cert.info.valido,
379            data_validade: cert.info.not_after.clone(),
380            dias_para_expirar: cert.info.dias_para_expirar as i32,
381        };
382
383        // Determinar ambiente
384        let amb = match ambiente.as_deref() {
385            Some("homologacao") | Some("2") => AmbienteNfe::Homologacao,
386            _ => AmbienteNfe::Producao,
387        };
388
389        // Criar cliente SEFAZ
390        let sefaz_client = SefazClient::new(cert.clone(), &uf, amb)
391            .map_err(|e| async_graphql::Error::new(format!("Erro ao criar cliente SEFAZ: {}", e)))?;
392
393        // Atualizar estado
394        if let Some(state) = ctx.data_opt::<Arc<RwLock<GraphQLState>>>() {
395            let mut state = state.write().await;
396            state.certificado = Some(cert);
397            state.sefaz_client = Some(sefaz_client);
398        }
399
400        Ok(info)
401    }
402
403    /// Parse XML de NF-e
404    async fn parse_xml(&self, xml: String) -> GqlResult<NfeType> {
405        // Usar parser existente
406        let xml_clean = xml.replace("xmlns=\"http://www.portalfiscal.inf.br/nfe\"", "");
407
408        let nfe: nfe_parser::Nfe = xml_clean.parse()
409            .map_err(|e| async_graphql::Error::new(format!("Erro ao parsear XML: {}", e)))?;
410
411        Ok(NfeType {
412            id: nfe.chave_acesso.clone(),
413            chave_acesso: nfe.chave_acesso,
414            numero: nfe.ide.numero as i32,
415            serie: nfe.ide.serie as i32,
416            tipo: TipoDocumento::Nfe,
417            ambiente: if nfe.ide.ambiente == nfe_parser::TipoAmbiente::Producao {
418                Ambiente::Producao
419            } else {
420                Ambiente::Homologacao
421            },
422            status: StatusNfe::Pendente,
423            data_emissao: nfe.ide.emissao.horario.format("%Y-%m-%dT%H:%M:%S").to_string(),
424            data_autorizacao: None,
425            protocolo: None,
426            emitente: EmitenteType {
427                cnpj: nfe.emit.cnpj.clone().unwrap_or_default(),
428                razao_social: nfe.emit.razao_social.clone().unwrap_or_default(),
429                nome_fantasia: nfe.emit.nome_fantasia.clone(),
430                inscricao_estadual: nfe.emit.ie.clone(),
431                endereco: EnderecoType {
432                    logradouro: nfe.emit.endereco.logradouro.clone(),
433                    numero: nfe.emit.endereco.numero.clone(),
434                    complemento: nfe.emit.endereco.complemento.clone(),
435                    bairro: nfe.emit.endereco.bairro.clone(),
436                    municipio: nfe.emit.endereco.nome_municipio.clone(),
437                    uf: nfe.emit.endereco.sigla_uf.clone(),
438                    cep: nfe.emit.endereco.cep.clone(),
439                    pais: Some("Brasil".to_string()),
440                },
441            },
442            destinatario: nfe.dest.as_ref().map(|d| DestinatarioType {
443                cnpj: Some(d.cnpj.clone()),
444                cpf: None,
445                razao_social: d.razao_social.clone().unwrap_or_default(),
446                inscricao_estadual: None,
447                endereco: d.endereco.as_ref().map(|e| EnderecoType {
448                    logradouro: e.logradouro.clone(),
449                    numero: e.numero.clone(),
450                    complemento: e.complemento.clone(),
451                    bairro: e.bairro.clone(),
452                    municipio: e.nome_municipio.clone(),
453                    uf: e.sigla_uf.clone(),
454                    cep: e.cep.clone(),
455                    pais: Some("Brasil".to_string()),
456                }),
457            }),
458            itens: nfe.itens.iter().map(|item| ItemType {
459                numero: item.numero as i32,
460                codigo: item.produto.codigo.clone(),
461                descricao: item.produto.descricao.clone(),
462                ncm: item.produto.ncm.clone(),
463                cfop: item.produto.tributacao.cfop.clone(),
464                unidade: item.produto.unidade.clone(),
465                quantidade: item.produto.quantidade as f64,
466                valor_unitario: item.produto.valor_unitario as f64,
467                valor_total: item.produto.valor_bruto as f64,
468            }).collect(),
469            totais: TotaisType {
470                base_calculo_icms: nfe.totais.valor_base_calculo as f64,
471                valor_icms: nfe.totais.valor_icms as f64,
472                valor_produtos: nfe.totais.valor_produtos as f64,
473                valor_frete: nfe.totais.valor_frete as f64,
474                valor_desconto: nfe.totais.valor_desconto as f64,
475                valor_total: nfe.totais.valor_total as f64,
476            },
477            xml: Some(xml),
478        })
479    }
480}
481
482/// Gera XML de NF-e a partir do input
483fn gerar_xml_nfe(input: &NfeInput) -> Result<String, async_graphql::Error> {
484    let mut xml = String::new();
485
486    // Header NFe
487    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
488    xml.push_str("<NFe xmlns=\"http://www.portalfiscal.inf.br/nfe\">");
489
490    // infNFe - gerar ID (chave de acesso será calculada)
491    let id_placeholder = format!("NFe{}", chrono::Utc::now().format("%Y%m%d%H%M%S%f"));
492    xml.push_str(&format!("<infNFe versao=\"4.00\" Id=\"{}\">", id_placeholder));
493
494    // ide - Identificação
495    xml.push_str("<ide>");
496    xml.push_str(&format!("<cUF>{}</cUF>", get_codigo_uf(&input.emitente.endereco.uf)));
497    xml.push_str(&format!("<cNF>{:08}</cNF>", chrono::Utc::now().timestamp() % 100000000));
498    xml.push_str("<natOp>Venda de mercadoria</natOp>");
499    // NF-e modelo 55 por padrão
500    xml.push_str("<mod>55</mod>");
501    xml.push_str(&format!("<serie>{}</serie>", input.serie));
502    xml.push_str(&format!("<nNF>{}</nNF>", input.numero));
503    xml.push_str(&format!("<dhEmi>{}</dhEmi>", chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S-03:00")));
504    xml.push_str("<tpNF>1</tpNF>"); // Saída
505    xml.push_str("<idDest>1</idDest>"); // Operação interna
506    xml.push_str(&format!("<cMunFG>{}</cMunFG>", get_codigo_municipio(&input.emitente.endereco.municipio)));
507    xml.push_str("<tpImp>1</tpImp>");
508    xml.push_str("<tpEmis>1</tpEmis>");
509    xml.push_str("<tpAmb>2</tpAmb>"); // Homologação por padrão
510    xml.push_str("<finNFe>1</finNFe>");
511    xml.push_str("<indFinal>1</indFinal>");
512    xml.push_str("<indPres>1</indPres>");
513    xml.push_str("<procEmi>0</procEmi>");
514    xml.push_str("<verProc>NfeWeb1.0</verProc>");
515    xml.push_str("</ide>");
516
517    // emit - Emitente
518    xml.push_str("<emit>");
519    xml.push_str(&format!("<CNPJ>{}</CNPJ>", input.emitente.cnpj));
520    xml.push_str(&format!("<xNome>{}</xNome>", input.emitente.razao_social));
521    if let Some(ref fantasia) = input.emitente.nome_fantasia {
522        xml.push_str(&format!("<xFant>{}</xFant>", fantasia));
523    }
524    xml.push_str("<enderEmit>");
525    xml.push_str(&format!("<xLgr>{}</xLgr>", input.emitente.endereco.logradouro));
526    xml.push_str(&format!("<nro>{}</nro>", input.emitente.endereco.numero));
527    xml.push_str(&format!("<xBairro>{}</xBairro>", input.emitente.endereco.bairro));
528    xml.push_str(&format!("<cMun>{}</cMun>", get_codigo_municipio(&input.emitente.endereco.municipio)));
529    xml.push_str(&format!("<xMun>{}</xMun>", input.emitente.endereco.municipio));
530    xml.push_str(&format!("<UF>{}</UF>", input.emitente.endereco.uf));
531    xml.push_str(&format!("<CEP>{}</CEP>", input.emitente.endereco.cep));
532    xml.push_str("<cPais>1058</cPais>");
533    xml.push_str("<xPais>Brasil</xPais>");
534    xml.push_str("</enderEmit>");
535    if let Some(ref ie) = input.emitente.inscricao_estadual {
536        xml.push_str(&format!("<IE>{}</IE>", ie));
537    }
538    xml.push_str("<CRT>1</CRT>"); // Simples Nacional
539    xml.push_str("</emit>");
540
541    // dest - Destinatário
542    if let Some(ref dest) = input.destinatario {
543        xml.push_str("<dest>");
544        if let Some(ref cnpj) = dest.cnpj {
545            xml.push_str(&format!("<CNPJ>{}</CNPJ>", cnpj));
546        } else if let Some(ref cpf) = dest.cpf {
547            xml.push_str(&format!("<CPF>{}</CPF>", cpf));
548        }
549        xml.push_str(&format!("<xNome>{}</xNome>", dest.razao_social));
550        if let Some(ref end) = dest.endereco {
551            xml.push_str("<enderDest>");
552            xml.push_str(&format!("<xLgr>{}</xLgr>", end.logradouro));
553            xml.push_str(&format!("<nro>{}</nro>", end.numero));
554            xml.push_str(&format!("<xBairro>{}</xBairro>", end.bairro));
555            xml.push_str(&format!("<cMun>{}</cMun>", get_codigo_municipio(&end.municipio)));
556            xml.push_str(&format!("<xMun>{}</xMun>", end.municipio));
557            xml.push_str(&format!("<UF>{}</UF>", end.uf));
558            xml.push_str(&format!("<CEP>{}</CEP>", end.cep));
559            xml.push_str("<cPais>1058</cPais>");
560            xml.push_str("<xPais>Brasil</xPais>");
561            xml.push_str("</enderDest>");
562        }
563        xml.push_str("<indIEDest>9</indIEDest>");
564        xml.push_str("</dest>");
565    }
566
567    // det - Itens
568    for (i, item) in input.itens.iter().enumerate() {
569        xml.push_str(&format!("<det nItem=\"{}\">", i + 1));
570        xml.push_str("<prod>");
571        xml.push_str(&format!("<cProd>{}</cProd>", item.codigo));
572        xml.push_str("<cEAN>SEM GTIN</cEAN>");
573        xml.push_str(&format!("<xProd>{}</xProd>", item.descricao));
574        xml.push_str(&format!("<NCM>{}</NCM>", item.ncm));
575        xml.push_str(&format!("<CFOP>{}</CFOP>", item.cfop));
576        xml.push_str(&format!("<uCom>{}</uCom>", item.unidade));
577        xml.push_str(&format!("<qCom>{:.4}</qCom>", item.quantidade));
578        xml.push_str(&format!("<vUnCom>{:.4}</vUnCom>", item.valor_unitario));
579        xml.push_str(&format!("<vProd>{:.2}</vProd>", item.quantidade * item.valor_unitario));
580        xml.push_str("<cEANTrib>SEM GTIN</cEANTrib>");
581        xml.push_str(&format!("<uTrib>{}</uTrib>", item.unidade));
582        xml.push_str(&format!("<qTrib>{:.4}</qTrib>", item.quantidade));
583        xml.push_str(&format!("<vUnTrib>{:.4}</vUnTrib>", item.valor_unitario));
584        xml.push_str("<indTot>1</indTot>");
585        xml.push_str("</prod>");
586        xml.push_str("<imposto>");
587        xml.push_str("<ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS>");
588        xml.push_str("<PIS><PISOutr><CST>99</CST><vBC>0.00</vBC><pPIS>0.00</pPIS><vPIS>0.00</vPIS></PISOutr></PIS>");
589        xml.push_str("<COFINS><COFINSOutr><CST>99</CST><vBC>0.00</vBC><pCOFINS>0.00</pCOFINS><vCOFINS>0.00</vCOFINS></COFINSOutr></COFINS>");
590        xml.push_str("</imposto>");
591        xml.push_str("</det>");
592    }
593
594    // total
595    let total_produtos: f64 = input.itens.iter().map(|i| i.quantidade * i.valor_unitario).sum();
596    xml.push_str("<total>");
597    xml.push_str("<ICMSTot>");
598    xml.push_str("<vBC>0.00</vBC>");
599    xml.push_str("<vICMS>0.00</vICMS>");
600    xml.push_str("<vICMSDeson>0.00</vICMSDeson>");
601    xml.push_str("<vFCP>0.00</vFCP>");
602    xml.push_str("<vBCST>0.00</vBCST>");
603    xml.push_str("<vST>0.00</vST>");
604    xml.push_str("<vFCPST>0.00</vFCPST>");
605    xml.push_str("<vFCPSTRet>0.00</vFCPSTRet>");
606    xml.push_str(&format!("<vProd>{:.2}</vProd>", total_produtos));
607    xml.push_str("<vFrete>0.00</vFrete>");
608    xml.push_str("<vSeg>0.00</vSeg>");
609    xml.push_str("<vDesc>0.00</vDesc>");
610    xml.push_str("<vII>0.00</vII>");
611    xml.push_str("<vIPI>0.00</vIPI>");
612    xml.push_str("<vIPIDevol>0.00</vIPIDevol>");
613    xml.push_str("<vPIS>0.00</vPIS>");
614    xml.push_str("<vCOFINS>0.00</vCOFINS>");
615    xml.push_str("<vOutro>0.00</vOutro>");
616    xml.push_str(&format!("<vNF>{:.2}</vNF>", total_produtos));
617    xml.push_str("</ICMSTot>");
618    xml.push_str("</total>");
619
620    // transp
621    xml.push_str("<transp>");
622    xml.push_str("<modFrete>9</modFrete>");
623    xml.push_str("</transp>");
624
625    // pag
626    xml.push_str("<pag>");
627    xml.push_str("<detPag>");
628    xml.push_str("<tPag>01</tPag>");
629    xml.push_str(&format!("<vPag>{:.2}</vPag>", total_produtos));
630    xml.push_str("</detPag>");
631    xml.push_str("</pag>");
632
633    xml.push_str("</infNFe>");
634    xml.push_str("</NFe>");
635
636    Ok(xml)
637}
638
639/// Obtém código UF
640fn get_codigo_uf(uf: &str) -> &str {
641    match uf {
642        "AC" => "12", "AL" => "27", "AP" => "16", "AM" => "13", "BA" => "29",
643        "CE" => "23", "DF" => "53", "ES" => "32", "GO" => "52", "MA" => "21",
644        "MT" => "51", "MS" => "50", "MG" => "31", "PA" => "15", "PB" => "25",
645        "PR" => "41", "PE" => "26", "PI" => "22", "RJ" => "33", "RN" => "24",
646        "RS" => "43", "RO" => "11", "RR" => "14", "SC" => "42", "SP" => "35",
647        "SE" => "28", "TO" => "17", _ => "35",
648    }
649}
650
651/// Obtém código município (placeholder)
652fn get_codigo_municipio(municipio: &str) -> String {
653    // Em produção, usar tabela IBGE
654    format!("{}0000", municipio.len())
655}