product and sales fixers
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
|
||||
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,60 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user