浏览代码

fix(s0): align sourcing field semantics

- unify is_active API bool? and DB '是/否' encoding via converter
- align currency (1=CNY) and supplier_type dictionaries; list shows CNY
- fix downstream active-state consumers (UniversalSourceList/DeliverySchedule)
- add mapping tests; bump Web 2.4.266 / server 1.0.284
YY968XX 1 周之前
父节点
当前提交
772a1482d3

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-  "version": "2.4.265",
+  "version": "2.4.266",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 6 - 4
Web/src/views/aidop/s0/api/s0SupplyApi.ts

@@ -117,7 +117,8 @@ export interface S0SrmPurchaseRow {
 	icitemId: number;
 	icitemName?: string | null;
 	supplierType?: string | null;
-	isActive: boolean;
+	// 后端已归一为 bool?:true=启用 / false=停用 / null=源值无法判定。
+	isActive?: boolean | null;
 	supplierId: number;
 	supplierName?: string | null;
 	supplierNumber?: string | null;
@@ -155,13 +156,14 @@ export interface S0SrmPurchaseUpsert {
 	icitemId: number;
 	icitemName?: string;
 	supplierType?: string;
-	isActive: boolean;
+	// 启用状态:对外 bool?(el-switch true/false,理论可空)。
+	isActive?: boolean | null;
 	supplierId: number;
 	supplierName?: string;
 	supplierNumber?: string;
 	orderPrice?: number | null;
-	// 币种:后端为 long?(数值可空)。UI 用字符串绑定字典选项,保存前规范化为数值|null,故类型放宽兼容三态
-	currencyType?: string | number | null;
+	// 币种:后端 long?(数值可空)。UI 选项 value 已归一为数值(1=CNY),前端边界保证只发送数值或 null
+	currencyType?: number | null;
 	taxrate?: number | null;
 	tariff?: number | null;
 	freight?: number | null;

+ 39 - 16
Web/src/views/aidop/s0/supply/SourcingList.vue

@@ -9,7 +9,7 @@
 			</el-form-item>
 			<el-form-item label="币种">
 				<el-select v-model="query.currencyType" clearable filterable placeholder="全部" style="width: 120px">
-					<el-option v-for="item in currencyOptions" :key="item.value" :label="item.label" :value="String(item.value)" />
+					<el-option v-for="item in currencyOptions" :key="item.value" :label="item.label" :value="item.value" />
 				</el-select>
 			</el-form-item>
 			<el-form-item label="启用">
@@ -34,12 +34,16 @@
 			<el-table-column prop="supplierType" label="供应类别" width="100" show-overflow-tooltip />
 			<el-table-column label="启用" width="72" align="center">
 				<template #default="{ row }">
-					<el-tag :type="row.isActive ? 'success' : 'info'" size="small">{{ row.isActive ? '是' : '否' }}</el-tag>
+					<el-tag v-if="row.isActive === true" type="success" size="small">是</el-tag>
+					<el-tag v-else-if="row.isActive === false" type="info" size="small">否</el-tag>
+					<span v-else>-</span>
 				</template>
 			</el-table-column>
 			<el-table-column prop="supplier" label="供应商" min-width="160" show-overflow-tooltip />
 			<el-table-column prop="orderPrice" label="不含税单价" width="100" align="right" />
-			<el-table-column prop="currencyType" label="币种" width="80" show-overflow-tooltip />
+			<el-table-column label="币种" width="80" show-overflow-tooltip>
+				<template #default="{ row }">{{ currencyLabel(row.currencyType) }}</template>
+			</el-table-column>
 			<el-table-column prop="taxrate" label="增值税率%" width="100" align="right" />
 			<el-table-column prop="quotaRate" label="配额比例%" width="100" align="right" />
 			<el-table-column label="采购前置期" width="120" align="center">
@@ -176,7 +180,7 @@
 					<el-col :span="12">
 						<el-form-item label="币种">
 							<el-select v-model="form.currencyType" clearable filterable style="width: 100%">
-								<el-option v-for="item in currencyOptions" :key="item.value" :label="item.label" :value="String(item.value)" />
+								<el-option v-for="item in currencyOptions" :key="item.value" :label="item.label" :value="item.value" />
 							</el-select>
 						</el-form-item>
 					</el-col>
@@ -283,7 +287,7 @@ const query = reactive({
 	companyRefId: undefined as string | undefined,
 	factoryRefId: undefined as string | undefined,
 	supplierType: '',
-	currencyType: '',
+	currencyType: undefined as number | undefined,
 	isActive: undefined as boolean | undefined,
 	page: 1,
 	pageSize: 20,
@@ -323,7 +327,7 @@ const form = reactive<S0SrmPurchaseUpsert & { icitemId: number; supplierId: numb
 	supplierName: '',
 	supplierNumber: '',
 	orderPrice: null,
-	currencyType: '',
+	currencyType: null,
 	taxrate: null,
 	tariff: null,
 	freight: null,
@@ -447,10 +451,21 @@ async function loadOptions() {
 	]);
 	companyOptions.value = companies;
 	factoryOptions.value = factories;
-	currencyOptions.value = currencies;
+	// 币种字典 value 归一为数值(业务确认仅 1=CNY);仅保留 value 为有限数值的项,
+	// 过滤掉历史遗留的非数值码(USD/EUR/JPY/HKD),避免 Number(非数值)=NaN 污染下拉与提交。
+	currencyOptions.value = currencies
+		.map((item) => ({ label: item.label, value: Number(item.value) }))
+		.filter((item) => Number.isFinite(item.value));
 	supplyCategoryOptions.value = supplyCat;
 }
 
+// 币种列表展示:按数值匹配字典 label(1→CNY);未命中回退显示原值,null/undefined 显示空。
+function currencyLabel(value: number | null | undefined): string {
+	if (value === null || value === undefined) return '';
+	const hit = currencyOptions.value.find((item) => Number(item.value) === Number(value));
+	return hit ? hit.label : String(value);
+}
+
 async function loadList() {
 	loading.value = true;
 	try {
@@ -481,7 +496,7 @@ function resetQuery() {
 	query.companyRefId = undefined;
 	query.factoryRefId = undefined;
 	query.supplierType = '';
-	query.currencyType = '';
+	query.currencyType = undefined;
 	query.isActive = undefined;
 	query.page = 1;
 	void loadList();
@@ -501,7 +516,7 @@ function resetForm() {
 		supplierName: '',
 		supplierNumber: '',
 		orderPrice: null,
-		currencyType: '',
+		currencyType: null,
 		taxrate: null,
 		tariff: null,
 		freight: null,
@@ -547,7 +562,9 @@ async function openEdit(row: S0SrmPurchaseRow) {
 		supplierName: row.supplierName ?? '',
 		supplierNumber: row.supplierNumber ?? '',
 		orderPrice: row.orderPrice ?? null,
-		currencyType: row.currencyType ?? '',
+		// 后端全局将 long 序列化为字符串(防雪花 ID 精度丢失),currencyType 到手可能是 "1";
+		// 编辑回填统一强转数值,避免与下拉数值选项发生 1 !== "1" 不匹配而回显空白。
+		currencyType: row.currencyType === null || row.currencyType === undefined ? null : Number(row.currencyType),
 		taxrate: row.taxrate ?? null,
 		tariff: row.tariff ?? null,
 		freight: row.freight ?? null,
@@ -584,21 +601,27 @@ async function openEdit(row: S0SrmPurchaseRow) {
 	}
 }
 
-// 币种字段后端为 long?(数值可空),而 s0_currency 字典给的是币种码字符串(如 CNY)、未选择时为空串,
-// 二者都无法绑定 long? 会导致保存 400。保存前规范化:纯数值串转数值,空串/非数值一律 null(可空放行)。
-function normalizeCurrencyType(v: string | number | null | undefined): number | null {
-	if (v === '' || v === null || v === undefined) return null;
+// 币种提交解析:选项 value 已是数值(1=CNY)。未选择=null 放行;已选但非有限数值(异常态)阻止提交,不静默转 null。
+function resolveCurrencyForSubmit(v: number | null | undefined): number | null {
+	if (v === null || v === undefined) return null;
 	const n = Number(v);
-	return Number.isInteger(n) ? n : null;
+	if (!Number.isFinite(n)) {
+		ElMessage.error('币种值无效,请重新选择');
+		throw new Error('invalid currencyType');
+	}
+	return n;
 }
 
 async function submitForm() {
 	await formRef.value?.validate();
 	// 提交前统一归属:缺则补默认、有则保留(含货源历史 0/0),不做 legacy↔雪花映射。
 	normalizeOrgScopeForSubmit(form, editingId.value ? 'edit' : 'create', companyOptions.value, factoryOptions.value);
+	const currencyType = resolveCurrencyForSubmit(form.currencyType);
+	// 供应类别提交前 trim;trim 后为空 → undefined(后端落 null),字典命中与否不强制改写用户原文。
+	const supplierType = form.supplierType?.trim() || undefined;
 	saving.value = true;
 	try {
-		const payload: S0SrmPurchaseUpsert = { ...form, currencyType: normalizeCurrencyType(form.currencyType) };
+		const payload: S0SrmPurchaseUpsert = { ...form, currencyType, supplierType };
 		if (editingId.value) {
 			await s0SrmPurchasesApi.update(editingId.value, payload);
 			ElMessage.success('已保存');

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.283</AssemblyVersion>
-    <FileVersion>1.0.283</FileVersion>
-    <Version>1.0.283</Version>
+    <AssemblyVersion>1.0.284</AssemblyVersion>
+    <FileVersion>1.0.284</FileVersion>
+    <Version>1.0.284</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 113 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Supply/AdoS0SrmPurchaseIsActiveTests.cs

@@ -0,0 +1,113 @@
+using Admin.NET.Plugin.AiDOP.Dto.S0.Supply;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Supply;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S0.Supply;
+
+/// <summary>
+/// S0 货源清单 srm_purchase.is_active 字符串列 ↔ bool? 口径统一转换与输出 DTO 映射单元测试。
+/// 覆盖:读取多编码兼容、写入“是/否”、未知非空值不静默判停用、查询用等值集合(非 Contains)、DTO 归一。
+/// </summary>
+public class AdoS0SrmPurchaseIsActiveTests
+{
+    [Theory]
+    [InlineData("是", true)]
+    [InlineData("Y", true)]
+    [InlineData("y", true)]
+    [InlineData("1", true)]
+    [InlineData("true", true)]
+    [InlineData("True", true)]
+    [InlineData("  是  ", true)]   // 前后空格:Trim 后命中
+    [InlineData("否", false)]
+    [InlineData("N", false)]
+    [InlineData("n", false)]
+    [InlineData("0", false)]
+    [InlineData("false", false)]
+    [InlineData("False", false)]
+    public void Parse_KnownTokens_ReturnsExpected(string input, bool expected)
+    {
+        Assert.Equal(expected, AdoS0SrmPurchaseIsActive.Parse(input));
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    [InlineData("未知")]
+    [InlineData("启用中")]
+    public void Parse_NullEmptyOrUnknown_ReturnsNull(string? input)
+    {
+        // 未知非空值必须返回 null,不得静默判成停用(false),以免把脏数据当停用处理。
+        Assert.Null(AdoS0SrmPurchaseIsActive.Parse(input));
+    }
+
+    [Theory]
+    [InlineData(true, "是")]
+    [InlineData(false, "否")]
+    public void Encode_Bool_ReturnsPersistedString(bool input, string expected)
+    {
+        Assert.Equal(expected, AdoS0SrmPurchaseIsActive.Encode(input));
+    }
+
+    [Fact]
+    public void Encode_Null_ReturnsNull()
+    {
+        Assert.Null(AdoS0SrmPurchaseIsActive.Encode(null));
+    }
+
+    [Fact]
+    public void RoundTrip_EncodeThenParse_Preserves()
+    {
+        Assert.True(AdoS0SrmPurchaseIsActive.Parse(AdoS0SrmPurchaseIsActive.EnabledValue));
+        Assert.False(AdoS0SrmPurchaseIsActive.Parse(AdoS0SrmPurchaseIsActive.DisabledValue));
+    }
+
+    [Fact]
+    public void EnabledSql_UsesEqualitySetNotContains()
+    {
+        var sql = AdoS0SrmPurchaseIsActive.EnabledSql("sp.is_active");
+        Assert.Contains("IN ('是','y','1','true')", sql);
+        Assert.Contains("TRIM(LOWER(sp.is_active))", sql);
+        Assert.DoesNotContain("LIKE", sql);
+        Assert.DoesNotContain("Contains", sql);
+    }
+
+    [Fact]
+    public void DisabledSql_UsesDisabledSet()
+    {
+        var sql = AdoS0SrmPurchaseIsActive.DisabledSql("sp.is_active");
+        Assert.Contains("IN ('否','n','0','false')", sql);
+        Assert.Contains("TRIM(LOWER(sp.is_active))", sql);
+    }
+
+    [Theory]
+    [InlineData("是", true)]
+    [InlineData("否", false)]
+    [InlineData(null, null)]
+    [InlineData("未知", null)]
+    public void FromEntity_MapsIsActiveToBool(string? dbValue, bool? expected)
+    {
+        var entity = new AdoS0SrmPurchase { Id = 1, IcitemId = 2, SupplierId = 3, IsActive = dbValue };
+        var dto = AdoS0SrmPurchaseDto.FromEntity(entity);
+        Assert.Equal(expected, dto.IsActive);
+    }
+
+    [Fact]
+    public void FromEntity_PassesCurrencyAndSupplierTypeThrough()
+    {
+        var entity = new AdoS0SrmPurchase
+        {
+            Id = 10,
+            IcitemId = 20,
+            SupplierId = 30,
+            IsActive = "是",
+            CurrencyType = 1,
+            SupplierType = "标准",
+        };
+        var dto = AdoS0SrmPurchaseDto.FromEntity(entity);
+        Assert.Equal(1, dto.CurrencyType);          // long? 数值原样透传(1=CNY)
+        Assert.Equal("标准", dto.SupplierType);       // 供应类别中文原文透传
+        Assert.True(dto.IsActive);
+    }
+}

+ 11 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SrmPurchasesController.cs

@@ -38,7 +38,9 @@ public class AdoS0SrmPurchasesController : ControllerBase
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
             .WhereIF(!string.IsNullOrWhiteSpace(q.SupplierType), x => x.SupplierType != null && x.SupplierType.Contains(q.SupplierType!))
-            .WhereIF(!string.IsNullOrWhiteSpace(q.IsActive), x => x.IsActive != null && x.IsActive.Contains(q.IsActive!))
+            // 启用筛选按启用/停用编码集合等值匹配(IN),废弃旧 Contains("true"/"false") 无法命中 DB“是”的错误口径。
+            .WhereIF(q.IsActive == true, AdoS0SrmPurchaseIsActive.EnabledSql("is_active"))
+            .WhereIF(q.IsActive == false, AdoS0SrmPurchaseIsActive.DisabledSql("is_active"))
             .WhereIF(!string.IsNullOrWhiteSpace(q.SupplierName), x => x.SupplierName != null && x.SupplierName.Contains(q.SupplierName!))
             .WhereIF(!string.IsNullOrWhiteSpace(q.SupplierNumber), x => x.SupplierNumber != null && x.SupplierNumber.Contains(q.SupplierNumber!))
             .WhereIF(q.CurrencyType.HasValue, x => x.CurrencyType == q.CurrencyType.Value);
@@ -70,7 +72,8 @@ public class AdoS0SrmPurchasesController : ControllerBase
 
         await ApplyItemAndSupplierDisplayAsync(list);
 
-        return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
+        var dtoList = list.Select(AdoS0SrmPurchaseDto.FromEntity).ToList();
+        return Ok(new { total, page = q.Page, pageSize = q.PageSize, list = dtoList });
     }
 
     [HttpGet("{id:long}")]
@@ -80,7 +83,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
         if (item == null) return NotFound();
 
         await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { item });
-        return Ok(item);
+        return Ok(AdoS0SrmPurchaseDto.FromEntity(item));
     }
 
     [HttpPost]
@@ -97,7 +100,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
 
         await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity });
-        return Ok(entity);
+        return Ok(AdoS0SrmPurchaseDto.FromEntity(entity));
     }
 
     [HttpPut("{id:long}")]
@@ -116,7 +119,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
 
         await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity });
-        return Ok(entity);
+        return Ok(AdoS0SrmPurchaseDto.FromEntity(entity));
     }
 
     [HttpDelete("{id:long}")]
@@ -154,8 +157,9 @@ public class AdoS0SrmPurchasesController : ControllerBase
         entity.DomainCode = dto.DomainCode;
         entity.IcitemId = dto.IcitemId;
         entity.IcitemName = dto.IcitemName;
-        entity.SupplierType = dto.SupplierType;
-        entity.IsActive = dto.IsActive;
+        entity.SupplierType = dto.SupplierType?.Trim();
+        // bool? → “是”/“否”/null 显式编码,不依赖 JSON bool 自动转 string。
+        entity.IsActive = AdoS0SrmPurchaseIsActive.Encode(dto.IsActive);
         entity.SupplierId = dto.SupplierId;
         entity.SupplierName = dto.SupplierName;
         entity.SupplierNumber = dto.SupplierNumber;

+ 98 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Supply/AdoS0SupplyDtos.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Entity.S0.Supply;
+
 namespace Admin.NET.Plugin.AiDOP.Dto.S0.Supply;
 
 /// <summary>供应商主数据启停(SuppMaster / IsActive)</summary>
@@ -150,7 +152,9 @@ public class AdoS0SrmPurchaseQueryDto
     public string? Keyword { get; set; }
 
     public string? SupplierType { get; set; }
-    public string? IsActive { get; set; }
+
+    /// <summary>启用筛选:true=启用集合 / false=停用集合 / null=不过滤。对外统一 bool?,与 DB varchar 口径由转换层桥接。</summary>
+    public bool? IsActive { get; set; }
     public string? SupplierName { get; set; }
     public string? SupplierNumber { get; set; }
     public long? CurrencyType { get; set; }
@@ -179,8 +183,9 @@ public class AdoS0SrmPurchaseUpsertDto
     public string? IcitemName { get; set; }
     [MaxLength(50)]
     public string? SupplierType { get; set; }
-    [MaxLength(64)]
-    public string? IsActive { get; set; }
+
+    /// <summary>启用状态:对外 bool?(true/false/null),落库经转换层编码为“是/否”/null。</summary>
+    public bool? IsActive { get; set; }
 
     [Range(1, long.MaxValue, ErrorMessage = "供应商不能为空")]
     public long SupplierId { get; set; }
@@ -215,6 +220,96 @@ public class AdoS0SrmPurchaseUpsertDto
     public string? UpdateUser { get; set; }
 }
 
+/// <summary>
+/// 货源清单对外输出 DTO(列表/详情/写入回执)。isActive 显式归一为 bool?,
+/// 禁止把实体 is_active 字符串直接暴露给前端;字段集合对齐前端 S0SrmPurchaseRow。
+/// </summary>
+public class AdoS0SrmPurchaseDto
+{
+    public long Id { get; set; }
+    public long CompanyRefId { get; set; }
+    public long FactoryRefId { get; set; }
+    public string? DomainCode { get; set; }
+    public long IcitemId { get; set; }
+    public string? IcitemName { get; set; }
+    public string? SupplierType { get; set; }
+
+    /// <summary>启用状态:true=启用 / false=停用 / null=未知(源字符串无法判定)。</summary>
+    public bool? IsActive { get; set; }
+    public long SupplierId { get; set; }
+    public string? SupplierName { get; set; }
+    public string? SupplierNumber { get; set; }
+    public decimal? OrderPrice { get; set; }
+    public long? CurrencyType { get; set; }
+    public decimal? Taxrate { get; set; }
+    public decimal? Tariff { get; set; }
+    public decimal? Freight { get; set; }
+    public string? PriceTerms { get; set; }
+    public DateTime? EffectiveDate { get; set; }
+    public DateTime? ExpiringDate { get; set; }
+    public decimal? QuotaRate { get; set; }
+    public decimal? LeadTime { get; set; }
+    public decimal? QtyMin { get; set; }
+    public decimal? PackagingQty { get; set; }
+    public string? OrderRectorName { get; set; }
+    public string? OrderRectorNum { get; set; }
+    public int IsRequireGoods { get; set; }
+
+    // 列表展示派生字段(由 Controller 左联物料主数据补齐)
+    public string? MaterialCode { get; set; }
+    public string? Model { get; set; }
+    public string? Unit { get; set; }
+    public string? ItemTypeLabel { get; set; }
+    public string? Icitem { get; set; }
+    public string? Supplier { get; set; }
+
+    public string? CreateUser { get; set; }
+    public DateTime CreateTime { get; set; }
+    public string? UpdateUser { get; set; }
+    public DateTime? UpdateTime { get; set; }
+
+    /// <summary>由实体映射为输出 DTO(is_active 字符串经 <see cref="AdoS0SrmPurchaseIsActive.Parse"/> 归一)。</summary>
+    public static AdoS0SrmPurchaseDto FromEntity(AdoS0SrmPurchase e) => new()
+    {
+        Id = e.Id,
+        CompanyRefId = e.CompanyRefId,
+        FactoryRefId = e.FactoryRefId,
+        DomainCode = e.DomainCode,
+        IcitemId = e.IcitemId,
+        IcitemName = e.IcitemName,
+        SupplierType = e.SupplierType,
+        IsActive = AdoS0SrmPurchaseIsActive.Parse(e.IsActive),
+        SupplierId = e.SupplierId,
+        SupplierName = e.SupplierName,
+        SupplierNumber = e.SupplierNumber,
+        OrderPrice = e.OrderPrice,
+        CurrencyType = e.CurrencyType,
+        Taxrate = e.Taxrate,
+        Tariff = e.Tariff,
+        Freight = e.Freight,
+        PriceTerms = e.PriceTerms,
+        EffectiveDate = e.EffectiveDate,
+        ExpiringDate = e.ExpiringDate,
+        QuotaRate = e.QuotaRate,
+        LeadTime = e.LeadTime,
+        QtyMin = e.QtyMin,
+        PackagingQty = e.PackagingQty,
+        OrderRectorName = e.OrderRectorName,
+        OrderRectorNum = e.OrderRectorNum,
+        IsRequireGoods = e.IsRequireGoods,
+        MaterialCode = e.MaterialCode,
+        Model = e.Model,
+        Unit = e.Unit,
+        ItemTypeLabel = e.ItemTypeLabel,
+        Icitem = e.Icitem,
+        Supplier = e.Supplier,
+        CreateUser = e.CreateUser,
+        CreateTime = e.CreateTime,
+        UpdateUser = e.UpdateUser,
+        UpdateTime = e.UpdateTime,
+    };
+}
+
 public class AdoS0MaterialPlanCycleQueryDto
 {
     public long? CompanyRefId { get; set; }

+ 58 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0SrmPurchaseIsActive.cs

@@ -0,0 +1,58 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// srm_purchase.is_active(varchar 列)↔ 布尔口径的唯一统一转换入口。
+/// 背景:该列历史全量存中文“是”,不同下游曾各自解释('是'/'Y'/'1'、CAST AS SIGNED 等),口径分裂。
+/// 本类固化:读取兼容多编码 → bool?;写入统一为“是/否”;未知非空值返回 null,绝不静默判停用。
+/// DB 与实体类型保持 string?,不改字段类型,不迁移存量数据。
+/// </summary>
+public static class AdoS0SrmPurchaseIsActive
+{
+    /// <summary>启用状态的持久化编码(写入 DB,与存量“是”对齐)。</summary>
+    public const string EnabledValue = "是";
+
+    /// <summary>停用状态的持久化编码(写入 DB)。</summary>
+    public const string DisabledValue = "否";
+
+    /// <summary>启用编码集合(读取判 true / 查询命中,均为小写归一后比较)。</summary>
+    public static readonly string[] EnabledTokens = { "是", "y", "1", "true" };
+
+    /// <summary>停用编码集合(读取判 false / 查询命中,均为小写归一后比较)。</summary>
+    public static readonly string[] DisabledTokens = { "否", "n", "0", "false" };
+
+    /// <summary>
+    /// DB 字符串 → bool?。规则:Trim 后按启用/停用集合判定;null/空白 → null;未知非空值 → null(不默认停用)。
+    /// </summary>
+    public static bool? Parse(string? value)
+    {
+        if (value == null) return null;
+        var trimmed = value.Trim();
+        if (trimmed.Length == 0) return null;
+        var lower = trimmed.ToLowerInvariant();
+        if (Array.IndexOf(EnabledTokens, lower) >= 0) return true;
+        if (Array.IndexOf(DisabledTokens, lower) >= 0) return false;
+        return null;
+    }
+
+    /// <summary>
+    /// bool? → DB 字符串。true → “是”;false → “否”;null → null(不写值)。
+    /// </summary>
+    public static string? Encode(bool? value)
+        => value switch
+        {
+            true => EnabledValue,
+            false => DisabledValue,
+            _ => null,
+        };
+
+    /// <summary>
+    /// 启用判定 SQL 片段(MySQL 方言)。col 为 is_active 列表达式(含表别名,如 sp.is_active)。
+    /// TRIM+LOWER 归一后 IN 启用集合;NULL/空/未知值天然不命中。
+    /// </summary>
+    public static string EnabledSql(string col)
+        => $"TRIM(LOWER({col})) IN ('是','y','1','true')";
+
+    /// <summary>停用判定 SQL 片段(MySQL 方言),语义同 <see cref="EnabledSql"/> 取停用集合。</summary>
+    public static string DisabledSql(string col)
+        => $"TRIM(LOWER({col})) IN ('否','n','0','false')";
+}

+ 12 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S0DictDataSeedData.cs

@@ -22,7 +22,7 @@ public class S0DictDataSeedData : ISqlSugarEntitySeedData<SysDictData>
         var docStatusId         = types[8].Id;
         var crTermsId           = types[9].Id;
         var taxClassId          = types[10].Id;
-        // types[11] = s0_supply_category(无数据条目)
+        var supplyCategoryId    = types[11].Id;
         var priorityCustomerTypeId = types[12].Id;
         var orderTypeId            = types[13].Id;
         var dueStatusId            = types[14].Id;
@@ -78,7 +78,11 @@ public class S0DictDataSeedData : ISqlSugarEntitySeedData<SysDictData>
             D(seq++, customerTypeId, "经销", "dealer",   102, ct),
 
             // ── s0_currency ──
-            D(seq++, currencyId, "CNY", "CNY", 100, ct),
+            // 业务确认:srm_purchase.currency_type 为 bigint,当前仅 1=CNY 真实存在。字典 value 归一为数值 "1"(label=CNY),
+            // 使 UI 选项 value 与 DB/后端 long? 口径一致(选中即发送 1,DB 值 1 可回填为 CNY)。
+            // USD/EUR/JPY/HKD 无已确认数值映射,保留原条目以维持 IncreSeed 的 Id 序稳定(幂等),
+            // 其 value 仍为非数值码,由前端“仅保留数值有效项”过滤掉,绝不虚构 2=USD 等映射。
+            D(seq++, currencyId, "CNY", "1", 100, ct),
             D(seq++, currencyId, "USD", "USD", 101, ct),
             D(seq++, currencyId, "EUR", "EUR", 102, ct),
             D(seq++, currencyId, "JPY", "JPY", 103, ct),
@@ -142,6 +146,12 @@ public class S0DictDataSeedData : ISqlSugarEntitySeedData<SysDictData>
             D(seq++, lineLocationId, "5007", "5007", 101, ct),
             D(seq++, lineLocationId, "8001", "8001", 102, ct),
             D(seq++, lineLocationId, "1002", "1002", 103, ct),
+
+            // ── s0_supply_category ──(srm_purchase.supplier_type,DB 存中文原文,value=label 直存原文)
+            // 追加在数组末尾:seq 自然续号,不移位任何既有字典 Id,保持 IncreSeed 幂等。
+            D(seq++, supplyCategoryId, "标准", "标准", 100, ct),
+            D(seq++, supplyCategoryId, "VMI",  "VMI",  101, ct),
+            D(seq++, supplyCategoryId, "委外", "委外", 102, ct),
         };
     }
 

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DeliveryScheduleService.cs

@@ -957,7 +957,8 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
             "IFNULL(sp.IsDeleted,0)=0",
             "IFNULL(sp.supplier_number,'') <> ''",
             "IFNULL(sp.quota_rate,0) > 0",
-            "(sp.is_active = '是' OR sp.is_active = 'Y' OR sp.is_active = '1')"
+            // 复用 srm_purchase.is_active 统一启用口径(与 UniversalSourceListService / Controller 同源),避免各自解释分裂。
+            AdoS0SrmPurchaseIsActive.EnabledSql("sp.is_active")
         };
         if (_userManager.TenantId > 0)
         {

+ 6 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalSourceListService.cs

@@ -76,7 +76,12 @@ public class UniversalSourceListService : IDynamicApiController, ITransient
                 it.model AS Model,
                 it.unit AS Unit,
                 sp.supplier_type AS SupplierType,
-                CAST(sp.is_active AS SIGNED) AS IsActive,
+                -- is_active 为 varchar(存“是/否”等),CAST AS SIGNED 会把“是”误判为 0;改按启用/停用编码集合归一为 1/0/NULL。
+                CASE
+                    WHEN TRIM(LOWER(sp.is_active)) IN ('是','y','1','true') THEN 1
+                    WHEN TRIM(LOWER(sp.is_active)) IN ('否','n','0','false') THEN 0
+                    ELSE NULL
+                END AS IsActive,
                 sp.supplier_id AS SupplierId,
                 sp.supplier_name AS SupplierName,
                 sp.supplier_number AS SupplierNumber,