feat: implement tenant product category management APIs
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s

This commit is contained in:
2026-02-20 18:45:48 +08:00
parent eea8a53da3
commit 1b3525862a
41 changed files with 9951 additions and 2 deletions

View File

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

View File

@@ -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)
{

View File

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

View File

@@ -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("并发控制字段。");