añadir cosas, apis y ajustes

This commit is contained in:
Алекс 2026-05-17 00:01:26 +02:00
parent 7c6ed689f6
commit 62bc070115
23 changed files with 697 additions and 37 deletions

View File

@ -19,9 +19,10 @@ public class ClientsController(ClientService clientService) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<ClientDto>>> GetClients( public async Task<ActionResult<List<ClientDto>>> GetClients(
[FromQuery] int limit = 50, [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); return Ok(clients);
} }

View File

@ -25,12 +25,12 @@ public class DocumentController(DocumentService documentService) : ControllerBas
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<DocumentItem>>> GetDocuments( public async Task<ActionResult<List<DocumentItem>>> GetDocuments(
[FromQuery] string modulePart = "invoice", [FromQuery] string modulePart = "invoice",
[FromQuery] string refId = "") [FromQuery] int id = 0)
{ {
if (string.IsNullOrEmpty(refId)) if (id <= 0)
return BadRequest("refId parameter is required"); return BadRequest("id parameter is required");
var documents = await documentService.GetDocumentsAsync(modulePart, refId); var documents = await documentService.GetDocumentsAsync(modulePart, id);
return Ok(documents); return Ok(documents);
} }

View File

@ -143,6 +143,16 @@ public class InvoicesController(InvoiceService invoiceService) : ControllerBase
return Ok(templates); 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<ActionResult<InvoiceDetailDto>> GetInvoiceTemplate([Range(1, int.MaxValue)] int id)
{
var template = await invoiceService.GetInvoiceTemplateAsync(id);
return Ok(template);
}
[HttpGet("{id:int}/payments")] [HttpGet("{id:int}/payments")]
[ProducesResponseType(typeof(List<InvoicePaymentDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<InvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]

View File

@ -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; }
}

View File

@ -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<SupplierInvoiceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<SupplierInvoiceDto>>> 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<ActionResult<int>> 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<ActionResult<SupplierInvoiceDetailDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<string>> 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<IActionResult> 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<IActionResult> 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<InvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<InvoicePaymentDto>>> 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<ActionResult<int>> AddPayment([Range(1, int.MaxValue)] int id, [FromBody] CreateInvoicePaymentDto dto)
{
var paymentId = await service.AddPaymentAsync(id, dto);
return StatusCode(StatusCodes.Status201Created, paymentId);
}
}

View File

@ -0,0 +1,6 @@
namespace DoliMiddlewareApi.Dtos.Dolibarr;
public class SupplierInvoiceDetailResponse : SupplierInvoiceResponse
{
public List<InvoiceLineResponse>? Lines { get; set; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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<TemplateInvoiceLineResponse>? lines { get; set; }
}

View File

@ -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<CreateInvoiceLineDto> Lines { get; set; } = [];
}

View File

@ -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; }
}

View File

@ -7,6 +7,8 @@ public class InvoiceDetailDto
public DateTime? Date { get; set; } public DateTime? Date { get; set; }
public DateTime? ExpireDate { get; set; } public DateTime? ExpireDate { get; set; }
public int ClientId { get; set; } public int ClientId { get; set; }
public decimal? TotalHt { get; set; }
public decimal? TotalTax { get; set; }
public decimal? Total { get; set; } public decimal? Total { get; set; }
public decimal? RemainToPay { get; set; } public decimal? RemainToPay { get; set; }
public string Status { get; set; } public string Status { get; set; }

View File

@ -0,0 +1,6 @@
namespace DoliMiddlewareApi.Dtos;
public class SupplierInvoiceDetailDto : SupplierInvoiceDto
{
public List<InvoiceLineDto>? Lines { get; set; }
}

View File

@ -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; }
}

View File

@ -60,6 +60,8 @@ public static class InvoiceMapper
Date = baseDto.Date, Date = baseDto.Date,
ExpireDate = baseDto.ExpireDate, ExpireDate = baseDto.ExpireDate,
ClientId = baseDto.ClientId, ClientId = baseDto.ClientId,
TotalHt = baseDto.TotalHt,
TotalTax = baseDto.TotalTax,
Total = baseDto.Total, Total = baseDto.Total,
RemainToPay = baseDto.RemainToPay, RemainToPay = baseDto.RemainToPay,
Status = baseDto.Status, 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) public static InvoicePaymentDto MapToInvoicePaymentDto(InvoicePaymentResponse response)
{ {
return new InvoicePaymentDto return new InvoicePaymentDto

View File

@ -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"
};
}

View File

@ -166,6 +166,9 @@ builder.Services.AddScoped<SetupService>();
// Servicio de negocio (banco) // Servicio de negocio (banco)
builder.Services.AddScoped<BankService>(); builder.Services.AddScoped<BankService>();
// Servicio de facturas de proveedores
builder.Services.AddScoped<SupplierInvoiceService>();
// Servicio de aplicación (orquesta login + cache) // Servicio de aplicación (orquesta login + cache)
builder.Services.AddScoped<AuthApplicationService>(); builder.Services.AddScoped<AuthApplicationService>();
@ -190,7 +193,9 @@ builder.Services.AddHttpClient("Webhook");
// NOTIFICACIONES — Webhook (Teams / Slack) // NOTIFICACIONES — Webhook (Teams / Slack)
// Configura Notifications:WebhookUrl en appsettings o variable de entorno // Configura Notifications:WebhookUrl en appsettings o variable de entorno
// Si no está configurado, las notificaciones se ignoran silenciosamente // Si no está configurado, las notificaciones se ignoran silenciosamente
// WebhookSettings es singleton para permitir actualización en tiempo de ejecución
// ========================================= // =========================================
builder.Services.AddSingleton<WebhookSettings>();
builder.Services.AddScoped<INotificationService, WebhookNotificationService>(); builder.Services.AddScoped<INotificationService, WebhookNotificationService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -9,9 +9,10 @@ namespace DoliMiddlewareApi.Services;
public class ClientService(IDolibarrApiClient apiClient) public class ClientService(IDolibarrApiClient apiClient)
{ {
public async Task<List<ClientDto>> GetClientsAsync(int limit = 50, int page = 1) public async Task<List<ClientDto>> GetClientsAsync(int limit = 50, int page = 1, bool supplier = false)
{ {
var endpoint = $"thirdparties?limit={limit}&page={page - 1}"; var endpoint = $"thirdparties?limit={limit}&page={page - 1}";
if (supplier) endpoint += "&mode=4";
var clients = await apiClient.GetCollectionAsync<ClientResponse>(endpoint); var clients = await apiClient.GetCollectionAsync<ClientResponse>(endpoint);

View File

@ -30,37 +30,52 @@ public class DocumentService(IDolibarrApiClient apiClient)
return (bytes, result.filename); return (bytes, result.filename);
} }
public async Task<List<DocumentItem>> GetDocumentsAsync(string modulePart, string refOrSocid) public async Task<List<DocumentItem>> GetDocumentsAsync(string modulePart, int id)
{ {
var endpoint = $"documents?modulepart={modulePart}&ref={Uri.EscapeDataString(refOrSocid)}"; try
var responseString = await apiClient.GetAsyncRaw(endpoint);
var response = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(responseString);
if (response == null)
return new List<DocumentItem>();
return response.Select(d =>
{ {
var name = d.TryGetValue("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String var endpoint = $"documents?modulepart={modulePart}&id={id}";
? nameProp.GetString() : null; var responseString = await apiClient.GetAsyncRaw(endpoint);
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;
return new DocumentItem var response = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(responseString);
if (response == null) return [];
return response.Select(d =>
{ {
Name = name, // Dolibarr's ECM array_merge overwrites 'name' with null (inherited CommonObject property).
Path = path, // Fall back to 'filename' which the ECM record keeps correctly.
Size = size var name = d.TryGetValue("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String
}; ? nameProp.GetString() : null;
}).ToList(); 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) 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 responseString = await apiClient.GetAsyncRaw(endpoint);
var response = JsonSerializer.Deserialize<DolibarrPdfResponse>(responseString); var response = JsonSerializer.Deserialize<DolibarrPdfResponse>(responseString);
@ -82,5 +97,6 @@ public class DocumentItem
{ {
public string? Name { get; set; } public string? Name { get; set; }
public string? Path { get; set; } public string? Path { get; set; }
public string? RelativePath { get; set; }
public long Size { get; set; } public long Size { get; set; }
} }

View File

@ -182,14 +182,51 @@ public class InvoiceService(IDolibarrApiClient apiClient, INotificationService n
public async Task<List<InvoiceDto>> GetInvoiceTemplatesAsync() public async Task<List<InvoiceDto>> GetInvoiceTemplatesAsync()
{ {
var dataList = await apiClient.GetCollectionAsync<InvoiceResponse>("invoices/templates"); // Dolibarr has no list endpoint for templates — only GET invoices/templates/{id}.
return dataList.Select(InvoiceMapper.MapToInvoiceDto).ToList(); // 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<TemplateInvoiceResponse>($"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<InvoiceDetailDto> GetInvoiceTemplateAsync(int id)
{
var data = await apiClient.GetResourceAsync<TemplateInvoiceResponse>($"invoices/templates/{id}");
if (data.id == 0) throw new NotFoundException($"Template invoice {id} not found");
return InvoiceMapper.MapTemplateToDetailDto(data);
} }
private async Task<Dictionary<int, string>> GetClientNamesAsync(List<int> clientIds) private async Task<Dictionary<int, string>> GetClientNamesAsync(List<int> clientIds)
{ {
var uniqueIds = clientIds.Distinct().ToList(); var validIds = clientIds.Distinct().Where(id => id > 0).ToList();
var tasks = uniqueIds.Select(id => apiClient.GetResourceAsync<ClientResponse>($"thirdparties/{id}")); if (validIds.Count == 0) return [];
var tasks = validIds.Select(async id =>
{
try { return await apiClient.GetResourceAsync<ClientResponse>($"thirdparties/{id}"); }
catch { return null; }
});
var clients = await Task.WhenAll(tasks); var clients = await Task.WhenAll(tasks);
return clients.Where(c => c is { id: not null }) return clients.Where(c => c is { id: not null })
.ToDictionary(c => int.Parse(c!.id!), c => c!.name ?? ""); .ToDictionary(c => int.Parse(c!.id!), c => c!.name ?? "");

View File

@ -10,7 +10,7 @@ namespace DoliMiddlewareApi.Services.Notifications;
/// </summary> /// </summary>
public class WebhookNotificationService( public class WebhookNotificationService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IConfiguration configuration, WebhookSettings webhookSettings,
ILogger<WebhookNotificationService> logger) : INotificationService ILogger<WebhookNotificationService> logger) : INotificationService
{ {
private static readonly Dictionary<string, (string label, string color)> StatusMeta = new() private static readonly Dictionary<string, (string label, string color)> StatusMeta = new()
@ -22,7 +22,7 @@ public class WebhookNotificationService(
public async Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus) public async Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus)
{ {
var webhookUrl = configuration["Notifications:WebhookUrl"]; var webhookUrl = webhookSettings.WebhookUrl;
if (string.IsNullOrWhiteSpace(webhookUrl)) if (string.IsNullOrWhiteSpace(webhookUrl))
return; // Notificaciones desactivadas — no hay webhook configurado return; // Notificaciones desactivadas — no hay webhook configurado
@ -62,7 +62,7 @@ public class WebhookNotificationService(
/// </summary> /// </summary>
private object BuildPayload(int invoiceId, string invoiceRef, string label, string color) 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")) if (webhookUrl.Contains("slack.com"))
{ {

View File

@ -0,0 +1,11 @@
namespace DoliMiddlewareApi.Services.Notifications;
public class WebhookSettings
{
public string? WebhookUrl { get; set; }
public WebhookSettings(IConfiguration configuration)
{
WebhookUrl = configuration["Notifications:WebhookUrl"];
}
}

View File

@ -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<List<SupplierInvoiceDto>> 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<SupplierInvoiceResponse>(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<SupplierInvoiceDetailDto> GetInvoiceAsync(int id)
{
var data = await apiClient.GetResourceAsync<SupplierInvoiceDetailResponse>($"supplierinvoices/{id}");
var dto = SupplierInvoiceMapper.MapToDetailDto(data);
if (dto.SupplierId > 0)
{
try
{
var supplier = await apiClient.GetResourceAsync<ClientResponse>($"thirdparties/{dto.SupplierId}");
dto.SupplierName = supplier?.name;
}
catch { /* supplier lookup is best-effort */ }
}
return dto;
}
public async Task<int> 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<SupplierInvoiceDetailResponse>($"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<SupplierInvoiceDetailResponse>($"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<string> AddLineAsync(int invoiceId, CreateInvoiceLineDto dto)
{
var invoice = await apiClient.GetResourceAsync<SupplierInvoiceDetailResponse>($"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<SupplierInvoiceDetailResponse>($"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<string, object?>
{
["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<SupplierInvoiceDetailResponse>($"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<List<InvoicePaymentDto>> GetPaymentsAsync(int invoiceId)
{
var dataList = await apiClient.GetCollectionAsync<InvoicePaymentResponse>($"supplierinvoices/{invoiceId}/payments");
return dataList.Select(InvoiceMapper.MapToInvoicePaymentDto).ToList();
}
public async Task<int> 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<Dictionary<int, string>> GetSupplierNamesAsync(List<int> 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<ClientResponse>($"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 ?? "");
}
}