diff --git a/DoliMiddlewareApi/Controllers/ClientsController.cs b/DoliMiddlewareApi/Controllers/ClientsController.cs index 86c8513..32f0003 100644 --- a/DoliMiddlewareApi/Controllers/ClientsController.cs +++ b/DoliMiddlewareApi/Controllers/ClientsController.cs @@ -19,9 +19,10 @@ public class ClientsController(ClientService clientService) : ControllerBase [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] public async Task>> GetClients( [FromQuery] int limit = 50, - [FromQuery][Range(1, int.MaxValue)] int page = 1) + [FromQuery][Range(1, int.MaxValue)] int page = 1, + [FromQuery] bool supplier = false) { - var clients = await clientService.GetClientsAsync(limit, page); + var clients = await clientService.GetClientsAsync(limit, page, supplier); return Ok(clients); } diff --git a/DoliMiddlewareApi/Controllers/DocumentController.cs b/DoliMiddlewareApi/Controllers/DocumentController.cs index 987cd1d..15f5d1a 100644 --- a/DoliMiddlewareApi/Controllers/DocumentController.cs +++ b/DoliMiddlewareApi/Controllers/DocumentController.cs @@ -25,12 +25,12 @@ public class DocumentController(DocumentService documentService) : ControllerBas [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] public async Task>> GetDocuments( [FromQuery] string modulePart = "invoice", - [FromQuery] string refId = "") + [FromQuery] int id = 0) { - if (string.IsNullOrEmpty(refId)) - return BadRequest("refId parameter is required"); + if (id <= 0) + return BadRequest("id parameter is required"); - var documents = await documentService.GetDocumentsAsync(modulePart, refId); + var documents = await documentService.GetDocumentsAsync(modulePart, id); return Ok(documents); } diff --git a/DoliMiddlewareApi/Controllers/InvoicesController.cs b/DoliMiddlewareApi/Controllers/InvoicesController.cs index e4b6cdd..7247a6a 100644 --- a/DoliMiddlewareApi/Controllers/InvoicesController.cs +++ b/DoliMiddlewareApi/Controllers/InvoicesController.cs @@ -143,6 +143,16 @@ public class InvoicesController(InvoiceService invoiceService) : ControllerBase return Ok(templates); } + [HttpGet("templates/{id:int}")] + [ProducesResponseType(typeof(InvoiceDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> GetInvoiceTemplate([Range(1, int.MaxValue)] int id) + { + var template = await invoiceService.GetInvoiceTemplateAsync(id); + return Ok(template); + } + [HttpGet("{id:int}/payments")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] diff --git a/DoliMiddlewareApi/Controllers/SettingsController.cs b/DoliMiddlewareApi/Controllers/SettingsController.cs new file mode 100644 index 0000000..7bb1ace --- /dev/null +++ b/DoliMiddlewareApi/Controllers/SettingsController.cs @@ -0,0 +1,32 @@ +using DoliMiddlewareApi.Services.Notifications; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DoliMiddlewareApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class SettingsController(WebhookSettings webhookSettings) : ControllerBase +{ + [HttpGet("webhook")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public IActionResult GetWebhook() + { + return Ok(new { url = webhookSettings.WebhookUrl }); + } + + [HttpPut("webhook")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public IActionResult SetWebhook([FromBody] SetWebhookDto dto) + { + webhookSettings.WebhookUrl = string.IsNullOrWhiteSpace(dto.Url) ? null : dto.Url.Trim(); + return NoContent(); + } +} + +public class SetWebhookDto +{ + public string? Url { get; set; } +} diff --git a/DoliMiddlewareApi/Controllers/SupplierInvoicesController.cs b/DoliMiddlewareApi/Controllers/SupplierInvoicesController.cs new file mode 100644 index 0000000..2aa7a33 --- /dev/null +++ b/DoliMiddlewareApi/Controllers/SupplierInvoicesController.cs @@ -0,0 +1,130 @@ +using System.ComponentModel.DataAnnotations; +using DoliMiddlewareApi.Dtos; +using DoliMiddlewareApi.Dtos.command; +using DoliMiddlewareApi.Dtos.query; +using DoliMiddlewareApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DoliMiddlewareApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class SupplierInvoicesController(SupplierInvoiceService service) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task>> GetInvoices( + [FromQuery] int limit = 50, + [FromQuery] [Range(1, int.MaxValue)] int page = 1, + [FromQuery] string? status = null) + { + var invoices = await service.GetInvoicesAsync(limit, page, status); + return Ok(invoices); + } + + [HttpPost] + [ProducesResponseType(typeof(int), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> CreateInvoice([FromBody] CreateSupplierInvoiceDto dto) + { + var id = await service.CreateInvoiceAsync(dto); + return CreatedAtAction(nameof(GetInvoice), new { id }, id); + } + + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(SupplierInvoiceDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> GetInvoice([Range(1, int.MaxValue)] int id) + { + var invoice = await service.GetInvoiceAsync(id); + return Ok(invoice); + } + + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task UpdateInvoice([Range(1, int.MaxValue)] int id, [FromBody] UpdateSupplierInvoiceDto dto) + { + await service.UpdateInvoiceAsync(id, dto); + return NoContent(); + } + + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task DeleteInvoice([Range(1, int.MaxValue)] int id) + { + await service.DeleteInvoiceAsync(id); + return NoContent(); + } + + [HttpPost("{id:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task ChangeStatus([Range(1, int.MaxValue)] int id, [FromBody] UpdateInvoiceStatusDto dto) + { + await service.ChangeStatusAsync(id, dto.Status); + return NoContent(); + } + + [HttpPost("{id:int}/lines")] + [ProducesResponseType(typeof(string), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> AddLine([Range(1, int.MaxValue)] int id, [FromBody] CreateInvoiceLineDto dto) + { + var result = await service.AddLineAsync(id, dto); + return StatusCode(StatusCodes.Status201Created, result); + } + + [HttpPut("{id:int}/lines/{lineId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task UpdateLine([Range(1, int.MaxValue)] int id, [Range(1, int.MaxValue)] int lineId, [FromBody] UpdateInvoiceLineDto dto) + { + await service.UpdateLineAsync(id, lineId, dto); + return NoContent(); + } + + [HttpDelete("{id:int}/lines/{lineId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task DeleteLine([Range(1, int.MaxValue)] int id, [Range(1, int.MaxValue)] int lineId) + { + await service.DeleteLineAsync(id, lineId); + return NoContent(); + } + + [HttpGet("{id:int}/payments")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task>> GetPayments([Range(1, int.MaxValue)] int id) + { + var payments = await service.GetPaymentsAsync(id); + return Ok(payments); + } + + [HttpPost("{id:int}/payments")] + [ProducesResponseType(typeof(int), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> AddPayment([Range(1, int.MaxValue)] int id, [FromBody] CreateInvoicePaymentDto dto) + { + var paymentId = await service.AddPaymentAsync(id, dto); + return StatusCode(StatusCodes.Status201Created, paymentId); + } +} diff --git a/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceDetailResponse.cs b/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceDetailResponse.cs new file mode 100644 index 0000000..13802b0 --- /dev/null +++ b/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceDetailResponse.cs @@ -0,0 +1,6 @@ +namespace DoliMiddlewareApi.Dtos.Dolibarr; + +public class SupplierInvoiceDetailResponse : SupplierInvoiceResponse +{ + public List? Lines { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceResponse.cs b/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceResponse.cs new file mode 100644 index 0000000..e5bfd62 --- /dev/null +++ b/DoliMiddlewareApi/Dtos/Dolibarr/SupplierInvoiceResponse.cs @@ -0,0 +1,18 @@ +namespace DoliMiddlewareApi.Dtos.Dolibarr; + +public class SupplierInvoiceResponse +{ + public string? id { get; set; } + public string? @ref { get; set; } + public string? ref_supplier { get; set; } + public long? date { get; set; } + public long? date_lim_reglement { get; set; } + public string? socid { get; set; } + public string? total_ht { get; set; } + public string? total_tva { get; set; } + public string? total_ttc { get; set; } + public string? remaintopay { get; set; } + public string? statut { get; set; } + public string? note_public { get; set; } + public string? note_private { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceLineResponse.cs b/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceLineResponse.cs new file mode 100644 index 0000000..c24519a --- /dev/null +++ b/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceLineResponse.cs @@ -0,0 +1,13 @@ +namespace DoliMiddlewareApi.Dtos.Dolibarr; + +public class TemplateInvoiceLineResponse +{ + public int id { get; set; } + public string? description { get; set; } + public decimal qty { get; set; } + public decimal subprice { get; set; } + public decimal tva_tx { get; set; } + public decimal total_ht { get; set; } + public decimal total_tva { get; set; } + public decimal total_ttc { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceResponse.cs b/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceResponse.cs new file mode 100644 index 0000000..6ccf41a --- /dev/null +++ b/DoliMiddlewareApi/Dtos/Dolibarr/TemplateInvoiceResponse.cs @@ -0,0 +1,14 @@ +namespace DoliMiddlewareApi.Dtos.Dolibarr; + +public class TemplateInvoiceResponse +{ + public int id { get; set; } + public string? @ref { get; set; } + public int socid { get; set; } + public decimal total_ht { get; set; } + public decimal total_tva { get; set; } + public decimal total_ttc { get; set; } + public string? note_public { get; set; } + public string? note_private { get; set; } + public List? lines { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/command/CreateSupplierInvoiceDto.cs b/DoliMiddlewareApi/Dtos/command/CreateSupplierInvoiceDto.cs new file mode 100644 index 0000000..d91c9a4 --- /dev/null +++ b/DoliMiddlewareApi/Dtos/command/CreateSupplierInvoiceDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace DoliMiddlewareApi.Dtos.command; + +public class CreateSupplierInvoiceDto +{ + [Required, Range(1, int.MaxValue)] + public int SupplierId { get; set; } + + [Required] + public string SupplierRef { get; set; } = ""; + + [Required] + public DateTime Date { get; set; } + + public DateTime? ExpireDate { get; set; } + + public string? NotePublic { get; set; } + public string? NotePrivate { get; set; } + + [Required, MinLength(1)] + public List Lines { get; set; } = []; +} diff --git a/DoliMiddlewareApi/Dtos/command/UpdateSupplierInvoiceDto.cs b/DoliMiddlewareApi/Dtos/command/UpdateSupplierInvoiceDto.cs new file mode 100644 index 0000000..35b679f --- /dev/null +++ b/DoliMiddlewareApi/Dtos/command/UpdateSupplierInvoiceDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace DoliMiddlewareApi.Dtos.command; + +public class UpdateSupplierInvoiceDto +{ + public DateTime? ExpireDate { get; set; } + + [StringLength(100)] + public string? SupplierRef { get; set; } + + public string? NotePublic { get; set; } + public string? NotePrivate { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/query/InvoiceDetailDto.cs b/DoliMiddlewareApi/Dtos/query/InvoiceDetailDto.cs index 52af6b3..94bc271 100644 --- a/DoliMiddlewareApi/Dtos/query/InvoiceDetailDto.cs +++ b/DoliMiddlewareApi/Dtos/query/InvoiceDetailDto.cs @@ -7,6 +7,8 @@ public class InvoiceDetailDto public DateTime? Date { get; set; } public DateTime? ExpireDate { get; set; } public int ClientId { get; set; } + public decimal? TotalHt { get; set; } + public decimal? TotalTax { get; set; } public decimal? Total { get; set; } public decimal? RemainToPay { get; set; } public string Status { get; set; } diff --git a/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDetailDto.cs b/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDetailDto.cs new file mode 100644 index 0000000..b1217ef --- /dev/null +++ b/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDetailDto.cs @@ -0,0 +1,6 @@ +namespace DoliMiddlewareApi.Dtos; + +public class SupplierInvoiceDetailDto : SupplierInvoiceDto +{ + public List? Lines { get; set; } +} diff --git a/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDto.cs b/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDto.cs new file mode 100644 index 0000000..050cfab --- /dev/null +++ b/DoliMiddlewareApi/Dtos/query/SupplierInvoiceDto.cs @@ -0,0 +1,19 @@ +namespace DoliMiddlewareApi.Dtos; + +public class SupplierInvoiceDto +{ + public int Id { get; set; } + public string? Number { get; set; } + public string? SupplierRef { get; set; } + public DateTime? Date { get; set; } + public DateTime? ExpireDate { get; set; } + public int SupplierId { get; set; } + public string? SupplierName { get; set; } + public decimal? TotalHt { get; set; } + public decimal? TotalTax { get; set; } + public decimal? Total { get; set; } + public decimal? RemainToPay { get; set; } + public string? Status { get; set; } + public string? NotePublic { get; set; } + public string? NotePrivate { get; set; } +} diff --git a/DoliMiddlewareApi/Mappers/InvoiceMapper.cs b/DoliMiddlewareApi/Mappers/InvoiceMapper.cs index 16b394a..87875fe 100644 --- a/DoliMiddlewareApi/Mappers/InvoiceMapper.cs +++ b/DoliMiddlewareApi/Mappers/InvoiceMapper.cs @@ -60,6 +60,8 @@ public static class InvoiceMapper Date = baseDto.Date, ExpireDate = baseDto.ExpireDate, ClientId = baseDto.ClientId, + TotalHt = baseDto.TotalHt, + TotalTax = baseDto.TotalTax, Total = baseDto.Total, RemainToPay = baseDto.RemainToPay, Status = baseDto.Status, @@ -121,6 +123,41 @@ public static class InvoiceMapper } + public static InvoiceDto MapTemplateToInvoiceDto(TemplateInvoiceResponse r) => new() + { + Id = r.id, + Number = r.@ref ?? "SIN-REF", + ClientId = r.socid, + TotalHt = Math.Round(r.total_ht, 2), + TotalTax = Math.Round(r.total_tva, 2), + Total = Math.Round(r.total_ttc, 2), + Status = "template", + NotePublic = r.note_public, + NotePrivate = r.note_private + }; + + public static InvoiceDetailDto MapTemplateToDetailDto(TemplateInvoiceResponse r) => new() + { + Id = r.id, + Number = r.@ref ?? "SIN-REF", + ClientId = r.socid, + TotalHt = Math.Round(r.total_ht, 2), + TotalTax = Math.Round(r.total_tva, 2), + Total = Math.Round(r.total_ttc, 2), + Status = "template", + NotePublic = r.note_public, + NotePrivate = r.note_private, + Lines = r.lines?.Select(l => new InvoiceLineDto + { + Id = l.id, + Description = l.description ?? "", + Quantity = l.qty, + UnitPrice = Math.Round(l.subprice, 2), + TaxRate = Math.Round(l.tva_tx, 2), + Total = Math.Round(l.total_ttc, 2) + }).ToList() + }; + public static InvoicePaymentDto MapToInvoicePaymentDto(InvoicePaymentResponse response) { return new InvoicePaymentDto diff --git a/DoliMiddlewareApi/Mappers/SupplierInvoiceMapper.cs b/DoliMiddlewareApi/Mappers/SupplierInvoiceMapper.cs new file mode 100644 index 0000000..301d6f7 --- /dev/null +++ b/DoliMiddlewareApi/Mappers/SupplierInvoiceMapper.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using DoliMiddlewareApi.Dtos; +using DoliMiddlewareApi.Dtos.Dolibarr; + +namespace DoliMiddlewareApi.Mappers; + +public static class SupplierInvoiceMapper +{ + public static SupplierInvoiceDto MapToDto(SupplierInvoiceResponse r) + { + return new SupplierInvoiceDto + { + Id = int.TryParse(r.id, out int id) ? id : 0, + Number = r.@ref ?? "SIN-REF", + SupplierRef = r.ref_supplier, + + Date = r.date.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(r.date.Value).DateTime + : null, + ExpireDate = r.date_lim_reglement.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(r.date_lim_reglement.Value).DateTime + : null, + + SupplierId = int.TryParse(r.socid, out int supplierId) ? supplierId : 0, + + TotalHt = Parse(r.total_ht), + TotalTax = Parse(r.total_tva), + Total = Parse(r.total_ttc), + RemainToPay = Parse(r.remaintopay), + + Status = ConvertStatus(r.statut), + NotePublic = r.note_public, + NotePrivate = r.note_private + }; + } + + public static SupplierInvoiceDetailDto MapToDetailDto(SupplierInvoiceDetailResponse r) + { + var base_ = MapToDto(r); + return new SupplierInvoiceDetailDto + { + Id = base_.Id, + Number = base_.Number, + SupplierRef = base_.SupplierRef, + Date = base_.Date, + ExpireDate = base_.ExpireDate, + SupplierId = base_.SupplierId, + TotalHt = base_.TotalHt, + TotalTax = base_.TotalTax, + Total = base_.Total, + RemainToPay = base_.RemainToPay, + Status = base_.Status, + NotePublic = base_.NotePublic, + NotePrivate = base_.NotePrivate, + Lines = r.Lines?.Select(InvoiceMapper.MapToInvoiceLineDto).ToList() ?? [] + }; + } + + private static decimal? Parse(string? value) => + decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) + ? Math.Round(d, 2) + : null; + + private static string ConvertStatus(string? statut) => statut switch + { + "0" => "draft", + "1" => "unpaid", + "2" => "paid", + "3" => "cancelled", + _ => "unknown" + }; +} diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index e993c7f..4adc6a8 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -166,6 +166,9 @@ builder.Services.AddScoped(); // Servicio de negocio (banco) builder.Services.AddScoped(); +// Servicio de facturas de proveedores +builder.Services.AddScoped(); + // Servicio de aplicación (orquesta login + cache) builder.Services.AddScoped(); @@ -190,7 +193,9 @@ builder.Services.AddHttpClient("Webhook"); // NOTIFICACIONES — Webhook (Teams / Slack) // Configura Notifications:WebhookUrl en appsettings o variable de entorno // Si no está configurado, las notificaciones se ignoran silenciosamente +// WebhookSettings es singleton para permitir actualización en tiempo de ejecución // ========================================= +builder.Services.AddSingleton(); builder.Services.AddScoped(); var app = builder.Build(); diff --git a/DoliMiddlewareApi/Services/ClientService.cs b/DoliMiddlewareApi/Services/ClientService.cs index 576daf3..398924c 100644 --- a/DoliMiddlewareApi/Services/ClientService.cs +++ b/DoliMiddlewareApi/Services/ClientService.cs @@ -9,9 +9,10 @@ namespace DoliMiddlewareApi.Services; public class ClientService(IDolibarrApiClient apiClient) { - public async Task> GetClientsAsync(int limit = 50, int page = 1) + public async Task> GetClientsAsync(int limit = 50, int page = 1, bool supplier = false) { var endpoint = $"thirdparties?limit={limit}&page={page - 1}"; + if (supplier) endpoint += "&mode=4"; var clients = await apiClient.GetCollectionAsync(endpoint); diff --git a/DoliMiddlewareApi/Services/DocumentService.cs b/DoliMiddlewareApi/Services/DocumentService.cs index 0bac4e5..7d7360a 100644 --- a/DoliMiddlewareApi/Services/DocumentService.cs +++ b/DoliMiddlewareApi/Services/DocumentService.cs @@ -30,37 +30,52 @@ public class DocumentService(IDolibarrApiClient apiClient) return (bytes, result.filename); } - public async Task> GetDocumentsAsync(string modulePart, string refOrSocid) + public async Task> GetDocumentsAsync(string modulePart, int id) { - var endpoint = $"documents?modulepart={modulePart}&ref={Uri.EscapeDataString(refOrSocid)}"; - var responseString = await apiClient.GetAsyncRaw(endpoint); - - var response = JsonSerializer.Deserialize>>(responseString); - - if (response == null) - return new List(); - - return response.Select(d => + try { - var name = d.TryGetValue("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String - ? nameProp.GetString() : null; - var path = d.TryGetValue("path", out var pathProp) && pathProp.ValueKind == JsonValueKind.String - ? pathProp.GetString() : null; - var size = d.TryGetValue("size", out var sizeProp) && sizeProp.ValueKind == JsonValueKind.Number - ? sizeProp.GetInt64() : 0; + var endpoint = $"documents?modulepart={modulePart}&id={id}"; + var responseString = await apiClient.GetAsyncRaw(endpoint); - return new DocumentItem + var response = JsonSerializer.Deserialize>>(responseString); + if (response == null) return []; + + return response.Select(d => { - Name = name, - Path = path, - Size = size - }; - }).ToList(); + // Dolibarr's ECM array_merge overwrites 'name' with null (inherited CommonObject property). + // Fall back to 'filename' which the ECM record keeps correctly. + var name = d.TryGetValue("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String + ? nameProp.GetString() : null; + if (string.IsNullOrEmpty(name)) + name = d.TryGetValue("filename", out var fnProp) && fnProp.ValueKind == JsonValueKind.String + ? fnProp.GetString() : null; + var path = d.TryGetValue("path", out var pathProp) && pathProp.ValueKind == JsonValueKind.String + ? pathProp.GetString() : null; + var size = d.TryGetValue("size", out var sizeProp) && sizeProp.ValueKind == JsonValueKind.Number + ? sizeProp.GetInt64() : 0; + + // level1name is the invoice ref directory (e.g. "IN2605-0001"). + // Dolibarr's download endpoint needs a relative path like "IN2605-0001/IN2605-0001.pdf", + // not the absolute filesystem path stored in 'path'. + var level1name = d.TryGetValue("level1name", out var l1Prop) && l1Prop.ValueKind == JsonValueKind.String + ? l1Prop.GetString() : null; + var relativePath = (!string.IsNullOrEmpty(level1name) && !string.IsNullOrEmpty(name)) + ? $"{level1name}/{name}" + : name; + + return new DocumentItem { Name = name, Path = path, Size = size, RelativePath = relativePath }; + }).ToList(); + } + catch + { + // Module may not be active in Dolibarr — return empty list gracefully + return []; + } } public async Task<(byte[] content, string filename, string contentType)> DownloadDocumentAsync(string modulePart, string fileRef) { - var endpoint = $"documents/download?modulepart={modulePart}&file={Uri.EscapeDataString(fileRef)}"; + var endpoint = $"documents/download?modulepart={modulePart}&original_file={Uri.EscapeDataString(fileRef)}"; var responseString = await apiClient.GetAsyncRaw(endpoint); var response = JsonSerializer.Deserialize(responseString); @@ -82,5 +97,6 @@ public class DocumentItem { public string? Name { get; set; } public string? Path { get; set; } + public string? RelativePath { get; set; } public long Size { get; set; } } \ No newline at end of file diff --git a/DoliMiddlewareApi/Services/InvoiceService.cs b/DoliMiddlewareApi/Services/InvoiceService.cs index 417741a..0946035 100644 --- a/DoliMiddlewareApi/Services/InvoiceService.cs +++ b/DoliMiddlewareApi/Services/InvoiceService.cs @@ -182,14 +182,51 @@ public class InvoiceService(IDolibarrApiClient apiClient, INotificationService n public async Task> GetInvoiceTemplatesAsync() { - var dataList = await apiClient.GetCollectionAsync("invoices/templates"); - return dataList.Select(InvoiceMapper.MapToInvoiceDto).ToList(); + // Dolibarr has no list endpoint for templates — only GET invoices/templates/{id}. + // FactureRec serializes numeric fields as JSON numbers (not strings), so we use + // TemplateInvoiceResponse with proper int/decimal types. + // Non-existent IDs return HTTP 200 with id=0 (Dolibarr quirk), so we filter by id > 0. + const int maxId = 50; + var tasks = Enumerable.Range(1, maxId).Select(async id => + { + try { return await apiClient.GetResourceAsync($"invoices/templates/{id}"); } + catch { return null; } + }); + + var results = await Task.WhenAll(tasks); + var dtos = results + .Where(r => r is { id: > 0 }) + .Select(r => InvoiceMapper.MapTemplateToInvoiceDto(r!)) + .ToList(); + + if (dtos.Count == 0) return dtos; + + var clientIds = dtos.Select(d => d.ClientId).Distinct().ToList(); + var names = await GetClientNamesAsync(clientIds); + foreach (var dto in dtos) + dto.ClientName = names.GetValueOrDefault(dto.ClientId); + + return dtos; + } + + public async Task GetInvoiceTemplateAsync(int id) + { + var data = await apiClient.GetResourceAsync($"invoices/templates/{id}"); + if (data.id == 0) throw new NotFoundException($"Template invoice {id} not found"); + return InvoiceMapper.MapTemplateToDetailDto(data); } private async Task> GetClientNamesAsync(List clientIds) { - var uniqueIds = clientIds.Distinct().ToList(); - var tasks = uniqueIds.Select(id => apiClient.GetResourceAsync($"thirdparties/{id}")); + var validIds = clientIds.Distinct().Where(id => id > 0).ToList(); + if (validIds.Count == 0) return []; + + var tasks = validIds.Select(async id => + { + try { return await apiClient.GetResourceAsync($"thirdparties/{id}"); } + catch { return null; } + }); + var clients = await Task.WhenAll(tasks); return clients.Where(c => c is { id: not null }) .ToDictionary(c => int.Parse(c!.id!), c => c!.name ?? ""); diff --git a/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs index 8e98ac0..c2a17ce 100644 --- a/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs +++ b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs @@ -10,7 +10,7 @@ namespace DoliMiddlewareApi.Services.Notifications; /// public class WebhookNotificationService( IHttpClientFactory httpClientFactory, - IConfiguration configuration, + WebhookSettings webhookSettings, ILogger logger) : INotificationService { private static readonly Dictionary StatusMeta = new() @@ -22,7 +22,7 @@ public class WebhookNotificationService( public async Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus) { - var webhookUrl = configuration["Notifications:WebhookUrl"]; + var webhookUrl = webhookSettings.WebhookUrl; if (string.IsNullOrWhiteSpace(webhookUrl)) return; // Notificaciones desactivadas — no hay webhook configurado @@ -62,7 +62,7 @@ public class WebhookNotificationService( /// private object BuildPayload(int invoiceId, string invoiceRef, string label, string color) { - var webhookUrl = configuration["Notifications:WebhookUrl"] ?? ""; + var webhookUrl = webhookSettings.WebhookUrl ?? ""; if (webhookUrl.Contains("slack.com")) { diff --git a/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs b/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs new file mode 100644 index 0000000..8cfe5c5 --- /dev/null +++ b/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs @@ -0,0 +1,11 @@ +namespace DoliMiddlewareApi.Services.Notifications; + +public class WebhookSettings +{ + public string? WebhookUrl { get; set; } + + public WebhookSettings(IConfiguration configuration) + { + WebhookUrl = configuration["Notifications:WebhookUrl"]; + } +} diff --git a/DoliMiddlewareApi/Services/SupplierInvoiceService.cs b/DoliMiddlewareApi/Services/SupplierInvoiceService.cs new file mode 100644 index 0000000..56dcd44 --- /dev/null +++ b/DoliMiddlewareApi/Services/SupplierInvoiceService.cs @@ -0,0 +1,193 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using DoliMiddlewareApi.Dtos; +using DoliMiddlewareApi.Dtos.Dolibarr; +using DoliMiddlewareApi.Dtos.command; +using DoliMiddlewareApi.Dtos.query; +using DoliMiddlewareApi.Exceptions; +using DoliMiddlewareApi.Mappers; +using DoliMiddlewareApi.Services.Clients; + +namespace DoliMiddlewareApi.Services; + +public class SupplierInvoiceService(IDolibarrApiClient apiClient) +{ + public async Task> GetInvoicesAsync(int limit, int page, string? status) + { + var endpoint = $"supplierinvoices?limit={limit}&page={page - 1}"; + if (!string.IsNullOrEmpty(status)) + endpoint += $"&status={status}"; + + var list = await apiClient.GetCollectionAsync(endpoint); + var dtos = list.Select(SupplierInvoiceMapper.MapToDto).ToList(); + + if (dtos.Count == 0) return dtos; + + var supplierIds = dtos.Select(d => d.SupplierId).Distinct().ToList(); + var names = await GetSupplierNamesAsync(supplierIds); + foreach (var dto in dtos) + dto.SupplierName = names.GetValueOrDefault(dto.SupplierId); + + return dtos; + } + + public async Task GetInvoiceAsync(int id) + { + var data = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + var dto = SupplierInvoiceMapper.MapToDetailDto(data); + + if (dto.SupplierId > 0) + { + try + { + var supplier = await apiClient.GetResourceAsync($"thirdparties/{dto.SupplierId}"); + dto.SupplierName = supplier?.name; + } + catch { /* supplier lookup is best-effort */ } + } + + return dto; + } + + public async Task CreateInvoiceAsync(CreateSupplierInvoiceDto dto) + { + var payload = new + { + socid = dto.SupplierId.ToString(), + ref_supplier = dto.SupplierRef, + date = ((DateTimeOffset)dto.Date).ToUnixTimeSeconds().ToString(), + date_lim_reglement = dto.ExpireDate.HasValue + ? ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds().ToString() + : null, + note_public = dto.NotePublic, + note_private = dto.NotePrivate, + lines = dto.Lines.Select(l => new + { + desc = l.Description, + qty = l.Quantity.ToString(CultureInfo.InvariantCulture), + subprice = l.UnitPrice.ToString(CultureInfo.InvariantCulture), + tva_tx = l.TaxRate.ToString(CultureInfo.InvariantCulture) + }).ToArray() + }; + + var result = await apiClient.PostAsync("supplierinvoices", payload); + return int.TryParse(result.Trim('"'), out int id) ? id : 0; + } + + public async Task UpdateInvoiceAsync(int id, UpdateSupplierInvoiceDto dto) + { + var current = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + + if (dto.SupplierRef != null) current.ref_supplier = dto.SupplierRef; + if (dto.NotePublic != null) current.note_public = dto.NotePublic; + if (dto.NotePrivate != null) current.note_private = dto.NotePrivate; + if (dto.ExpireDate.HasValue) + current.date_lim_reglement = ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds(); + + await apiClient.PutAsync($"supplierinvoices/{id}", current); + } + + public async Task DeleteInvoiceAsync(int id) + { + var invoice = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden eliminar facturas de proveedor en borrador"); + + await apiClient.DeleteAsync($"supplierinvoices/{id}"); + } + + public async Task ChangeStatusAsync(int id, string status) + { + var normalized = status.Trim().ToLowerInvariant(); + var endpoint = normalized switch + { + "draft" => $"supplierinvoices/{id}/settodraft", + "unpaid" => $"supplierinvoices/{id}/validate", + "paid" => $"supplierinvoices/{id}/setpaid", + _ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.") + }; + + await apiClient.PostAsync(endpoint, new { }); + } + + public async Task AddLineAsync(int invoiceId, CreateInvoiceLineDto dto) + { + var invoice = await apiClient.GetResourceAsync($"supplierinvoices/{invoiceId}"); + if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden añadir líneas a facturas en borrador"); + + var requestBody = new + { + desc = dto.Description, + qty = dto.Quantity.ToString(CultureInfo.InvariantCulture), + subprice = dto.UnitPrice.ToString(CultureInfo.InvariantCulture), + tva_tx = dto.TaxRate.ToString(CultureInfo.InvariantCulture) + }; + + return await apiClient.PostAsync($"supplierinvoices/{invoiceId}/lines", requestBody); + } + + public async Task UpdateLineAsync(int invoiceId, int lineId, UpdateInvoiceLineDto dto) + { + var invoice = await apiClient.GetResourceAsync($"supplierinvoices/{invoiceId}"); + if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden modificar líneas de facturas en borrador"); + + var existingLine = invoice.Lines?.FirstOrDefault(l => l.id == lineId.ToString()); + if (existingLine == null) throw new NotFoundException($"Línea {lineId} no encontrada en factura {invoiceId}"); + + var requestBody = new Dictionary + { + ["desc"] = dto.Description ?? existingLine.description ?? existingLine.desc, + ["qty"] = dto.Quantity?.ToString(CultureInfo.InvariantCulture) ?? existingLine.qty, + ["subprice"] = dto.UnitPrice?.ToString(CultureInfo.InvariantCulture) ?? existingLine.subprice, + ["tva_tx"] = dto.TaxRate?.ToString(CultureInfo.InvariantCulture) ?? existingLine.tva_tx + }; + + await apiClient.PutAsync($"supplierinvoices/{invoiceId}/lines/{lineId}", requestBody); + } + + public async Task DeleteLineAsync(int invoiceId, int lineId) + { + var invoice = await apiClient.GetResourceAsync($"supplierinvoices/{invoiceId}"); + if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden eliminar líneas de facturas en borrador"); + + await apiClient.DeleteAsync($"supplierinvoices/{invoiceId}/lines/{lineId}"); + } + + public async Task> GetPaymentsAsync(int invoiceId) + { + var dataList = await apiClient.GetCollectionAsync($"supplierinvoices/{invoiceId}/payments"); + return dataList.Select(InvoiceMapper.MapToInvoicePaymentDto).ToList(); + } + + public async Task AddPaymentAsync(int invoiceId, CreateInvoicePaymentDto dto) + { + var paymentModeId = dto.PaymentMethodId ?? dto.PaymentModeId; + var requestBody = new + { + datepaye = dto.PaymentDate.ToString("yyyy-MM-dd"), + paymentid = paymentModeId, + closepaidinvoices = dto.ClosePaidInvoices, + accountid = dto.AccountId, + num_payment = dto.PaymentNumber, + amount = dto.Amount?.ToString(CultureInfo.InvariantCulture) + }; + var responseBody = await apiClient.PostAsync($"supplierinvoices/{invoiceId}/payments", requestBody); + return int.TryParse(responseBody.Trim('"'), out int result) ? result : 0; + } + + private async Task> GetSupplierNamesAsync(List ids) + { + var validIds = ids.Distinct().Where(id => id > 0).ToList(); + if (validIds.Count == 0) return []; + + var tasks = validIds.Select(async id => + { + try { return await apiClient.GetResourceAsync($"thirdparties/{id}"); } + catch { return null; } + }); + + var results = await Task.WhenAll(tasks); + return results + .Where(c => c is { id: not null }) + .ToDictionary(c => int.Parse(c!.id!), c => c!.name ?? ""); + } +}