feat: implement tenant product category management APIs
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
This commit is contained in:
@@ -687,6 +687,8 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(256);
|
||||
builder.Property(x => x.Icon).HasMaxLength(512);
|
||||
builder.Property(x => x.ChannelsJson).HasColumnType("text").HasDefaultValue("[\"wm\"]");
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Products.Entities;
|
||||
@@ -87,6 +88,123 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return categories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProductCategory?> FindCategoryByIdAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.ProductCategories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == categoryId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsCategoryNameAsync(long tenantId, long storeId, string name, long? excludeCategoryId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedName = (name ?? string.Empty).Trim();
|
||||
var normalizedLower = normalizedName.ToLowerInvariant();
|
||||
var query = context.ProductCategories
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.StoreId == storeId &&
|
||||
x.Name.ToLower() == normalizedLower);
|
||||
|
||||
if (excludeCategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeCategoryId.Value);
|
||||
}
|
||||
|
||||
return query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, int>> CountProductsByCategoryIdsAsync(long tenantId, long storeId, IReadOnlyCollection<long> categoryIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (categoryIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.Products
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && categoryIds.Contains(x.CategoryId))
|
||||
.GroupBy(x => x.CategoryId)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Products
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
|
||||
|
||||
if (categoryId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.CategoryId == categoryId.Value);
|
||||
}
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var loweredKeyword = normalizedKeyword.ToLowerInvariant();
|
||||
query = query.Where(x =>
|
||||
x.Name.ToLower().Contains(loweredKeyword) ||
|
||||
x.SpuCode.ToLower().Contains(loweredKeyword));
|
||||
}
|
||||
|
||||
var normalizedLimit = Math.Clamp(limit, 1, 500);
|
||||
return await query
|
||||
.OrderBy(x => x.Name)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(normalizedLimit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var products = await context.Products
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.StoreId == storeId &&
|
||||
productIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var product in products)
|
||||
{
|
||||
product.CategoryId = categoryId;
|
||||
}
|
||||
|
||||
return products.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MoveProductToCategoryAsync(long tenantId, long storeId, long fromCategoryId, long toCategoryId, long productId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var product = await context.Products
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.StoreId == storeId &&
|
||||
x.Id == productId &&
|
||||
x.CategoryId == fromCategoryId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (product is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
product.CategoryId = toCategoryId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductCategoryIconAndChannels : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ChannelsJson",
|
||||
table: "product_categories",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "[\"wm\"]",
|
||||
comment: "分类可见渠道 JSON。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Icon",
|
||||
table: "product_categories",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true,
|
||||
comment: "分类图标。");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChannelsJson",
|
||||
table: "product_categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Icon",
|
||||
table: "product_categories");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4351,6 +4351,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ChannelsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("[\"wm\"]")
|
||||
.HasComment("分类可见渠道 JSON。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
@@ -4372,6 +4379,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("分类描述。");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("分类图标。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否启用。");
|
||||
@@ -5862,7 +5874,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
@@ -5949,7 +5960,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user