diff --git a/PackagingMallShipper/Data/SqliteHelper.cs b/PackagingMallShipper/Data/SqliteHelper.cs index aa17440..61e55dc 100644 --- a/PackagingMallShipper/Data/SqliteHelper.cs +++ b/PackagingMallShipper/Data/SqliteHelper.cs @@ -38,6 +38,82 @@ namespace PackagingMallShipper.Data SQLiteConnection.CreateFile(_dbPath); CreateTables(); } + else + { + // 对现有数据库进行升级(添加新表) + UpgradeDatabase(); + } + } + + private static void UpgradeDatabase() + { + try + { + using (var conn = GetConnection()) + { + conn.Open(); + // 检查 products_cache 表是否存在,不存在则创建 + var checkSql = "SELECT name FROM sqlite_master WHERE type='table' AND name='products_cache'"; + using (var cmd = new SQLiteCommand(checkSql, conn)) + { + var result = cmd.ExecuteScalar(); + if (result == null) + { + Debug.WriteLine($"[数据库升级] 创建 products_cache 表"); + using (var createCmd = new SQLiteCommand(GetProductsTableSchema(), conn)) + { + createCmd.ExecuteNonQuery(); + } + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[数据库升级] 升级失败: {ex.Message}"); + } + } + + private static string GetProductsTableSchema() + { + return @" +-- 产品缓存表 +CREATE TABLE IF NOT EXISTS products_cache ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + bar_code TEXT, + yy_id TEXT, + item_code TEXT, + min_price REAL, + original_price REAL, + stores INTEGER DEFAULT 0, + status INTEGER DEFAULT 0, + category_id INTEGER, + unit TEXT, + pics_json TEXT, + is_local_only INTEGER DEFAULT 0, + local_note TEXT, + local_created_at DATETIME, + local_updated_at DATETIME, + synced_at DATETIME, + pickup_price REAL, + express_price REAL, + pack_price REAL, + pack_quantity INTEGER, + product_series TEXT, + placement_method TEXT, + thickness TEXT, + hole_size TEXT, + box_size TEXT, + applicable_type TEXT +); + +CREATE INDEX IF NOT EXISTS idx_products_name ON products_cache(name); +CREATE INDEX IF NOT EXISTS idx_products_bar_code ON products_cache(bar_code); +CREATE INDEX IF NOT EXISTS idx_products_yy_id ON products_cache(yy_id); +CREATE INDEX IF NOT EXISTS idx_products_item_code ON products_cache(item_code); +CREATE INDEX IF NOT EXISTS idx_products_status ON products_cache(status); +"; } public static SQLiteConnection GetConnection() @@ -254,5 +330,11 @@ CREATE INDEX IF NOT EXISTS idx_sync_logs_status ON sync_logs(status); var ordinal = reader.GetOrdinal(column); return reader.IsDBNull(ordinal) ? (int?)null : reader.GetInt32(ordinal); } + + public static decimal? GetDecimalNullable(this SQLiteDataReader reader, string column) + { + var ordinal = reader.GetOrdinal(column); + return reader.IsDBNull(ordinal) ? (decimal?)null : reader.GetDecimal(ordinal); + } } } diff --git a/PackagingMallShipper/Models/Order.cs b/PackagingMallShipper/Models/Order.cs index 4573243..3096459 100644 --- a/PackagingMallShipper/Models/Order.cs +++ b/PackagingMallShipper/Models/Order.cs @@ -77,6 +77,38 @@ namespace PackagingMallShipper.Models } } } + + public List GetGoodsIds() + { + if (string.IsNullOrEmpty(GoodsJson)) return new List(); + try + { + var goods = JsonConvert.DeserializeObject>(GoodsJson); + return goods.Select(g => g.GoodsId).Distinct().ToList(); + } + catch + { + return new List(); + } + } + + public string GetItemCodesInfo(Dictionary itemCodeMap) + { + if (string.IsNullOrEmpty(GoodsJson) || itemCodeMap == null) return ""; + try + { + var goods = JsonConvert.DeserializeObject>(GoodsJson); + var codes = goods + .Where(g => itemCodeMap.ContainsKey(g.GoodsId)) + .Select(g => $"{itemCodeMap[g.GoodsId]}x{g.Number}") + .Where(c => !string.IsNullOrEmpty(c)); + return string.Join("; ", codes); + } + catch + { + return ""; + } + } } public class GoodsItem diff --git a/PackagingMallShipper/Models/Product.cs b/PackagingMallShipper/Models/Product.cs new file mode 100644 index 0000000..7f24cf0 --- /dev/null +++ b/PackagingMallShipper/Models/Product.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PackagingMallShipper.Models +{ + /// + /// 本地产品模型 + /// + public class Product + { + /// + /// 商品ID(来自API) + /// + public int Id { get; set; } + + /// + /// 商品名称 + /// + public string Name { get; set; } + + /// + /// 条码编号(来自API的barCode) + /// + public string BarCode { get; set; } + + /// + /// 外部商品编号(来自API的yyId) + /// + public string YyId { get; set; } + + /// + /// 本地自定义货品编号(用户可编辑) + /// 格式如:1222003x12+13222003x24+1322003x12+0000000x12 + /// + public string ItemCode { get; set; } + + /// + /// 现价 + /// + public decimal MinPrice { get; set; } + + /// + /// 原价 + /// + public decimal OriginalPrice { get; set; } + + /// + /// 库存 + /// + public int Stores { get; set; } + + /// + /// 状态(0:上架, 1:下架) + /// + public int Status { get; set; } + + /// + /// 分类ID + /// + public int? CategoryId { get; set; } + + /// + /// 单位 + /// + public string Unit { get; set; } + + /// + /// 图片数组JSON + /// + public string PicsJson { get; set; } + + // ========== 本地管理字段 ========== + + /// + /// 是否为纯本地产品(未同步到远程) + /// + public bool IsLocalOnly { get; set; } + + /// + /// 本地备注 + /// + public string LocalNote { get; set; } + + public DateTime? LocalCreatedAt { get; set; } + public DateTime? LocalUpdatedAt { get; set; } + public DateTime? SyncedAt { get; set; } + + // ========== 扩展价格字段(对应产品报价表)========== + + /// + /// 自提开票价(元/套) + /// + public decimal? PickupPrice { get; set; } + + /// + /// 快递开票价(元/套) + /// + public decimal? ExpressPrice { get; set; } + + /// + /// 包价格(元/箱) + /// + public decimal? PackPrice { get; set; } + + /// + /// 打包数量(套/箱) + /// + public int? PackQuantity { get; set; } + + // ========== 规格参数 ========== + + /// + /// 产品系列 + /// + public string ProductSeries { get; set; } + + /// + /// 放置方式 + /// + public string PlacementMethod { get; set; } + + /// + /// 厚度 + /// + public string Thickness { get; set; } + + /// + /// 孔径尺寸 + /// + public string HoleSize { get; set; } + + /// + /// 纸箱尺寸 + /// + public string BoxSize { get; set; } + + /// + /// 适用蛋型 + /// + public string ApplicableType { get; set; } + + // ========== 计算属性 ========== + + /// + /// 状态文本 + /// + public string StatusText => Status == 0 ? "上架" : "下架"; + + /// + /// 首张图片URL + /// + public string FirstPic + { + get + { + if (string.IsNullOrEmpty(PicsJson)) return null; + try + { + var pics = JsonConvert.DeserializeObject>(PicsJson); + return pics?.Count > 0 ? pics[0] : null; + } + catch + { + return null; + } + } + } + + /// + /// 是否已设置货品编号 + /// + public bool HasItemCode => !string.IsNullOrWhiteSpace(ItemCode); + + /// + /// 用于UI选择 + /// + public bool IsSelected { get; set; } + } + + /// + /// 产品状态枚举 + /// + public static class ProductStatus + { + public const int OnSale = 0; // 上架 + public const int OffSale = 1; // 下架 + + public static string GetStatusText(int status) + { + return status == OnSale ? "上架" : "下架"; + } + } + + /// + /// 产品列表API响应数据 + /// + public class ProductListData + { + [JsonProperty("result")] + public List Result { get; set; } + + [JsonProperty("list")] + public List List { get; set; } + + [JsonProperty("totalRow")] + public int TotalRow { get; set; } + + [JsonProperty("total")] + public int Total { get; set; } + + [JsonProperty("totalPage")] + public int TotalPage { get; set; } + + /// + /// 获取产品列表(兼容多种返回格式) + /// + public List GetProducts() + { + return Result ?? List ?? new List(); + } + + public int GetTotalRow() + { + return TotalRow > 0 ? TotalRow : Total; + } + } + + /// + /// API返回的产品DTO + /// + public class ProductDto + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("barCode")] + public string BarCode { get; set; } + + [JsonProperty("yyId")] + public string YyId { get; set; } + + [JsonProperty("minPrice")] + public decimal MinPrice { get; set; } + + [JsonProperty("originalPrice")] + public decimal OriginalPrice { get; set; } + + [JsonProperty("stores")] + public int Stores { get; set; } + + [JsonProperty("status")] + public int Status { get; set; } + + [JsonProperty("categoryId")] + public int? CategoryId { get; set; } + + [JsonProperty("unit")] + public string Unit { get; set; } + + [JsonProperty("pics")] + public List Pics { get; set; } + + /// + /// 转换为本地Product模型 + /// + public Product ToProduct(Product existingProduct = null) + { + return new Product + { + Id = Id, + Name = Name, + BarCode = BarCode, + YyId = YyId, + ItemCode = existingProduct?.ItemCode, + MinPrice = MinPrice, + OriginalPrice = OriginalPrice, + Stores = Stores, + Status = Status, + CategoryId = CategoryId, + Unit = Unit, + PicsJson = Pics != null ? JsonConvert.SerializeObject(Pics) : null, + IsLocalOnly = false, + LocalNote = existingProduct?.LocalNote, + PickupPrice = existingProduct?.PickupPrice, + ExpressPrice = existingProduct?.ExpressPrice, + PackPrice = existingProduct?.PackPrice, + PackQuantity = existingProduct?.PackQuantity, + ProductSeries = existingProduct?.ProductSeries, + PlacementMethod = existingProduct?.PlacementMethod, + Thickness = existingProduct?.Thickness, + HoleSize = existingProduct?.HoleSize, + BoxSize = existingProduct?.BoxSize, + ApplicableType = existingProduct?.ApplicableType, + SyncedAt = DateTime.Now + }; + } + } +} diff --git a/PackagingMallShipper/Services/ExcelService.cs b/PackagingMallShipper/Services/ExcelService.cs index 1879c2e..bc62284 100644 --- a/PackagingMallShipper/Services/ExcelService.cs +++ b/PackagingMallShipper/Services/ExcelService.cs @@ -12,17 +12,25 @@ namespace PackagingMallShipper.Services { private readonly IOrderService _orderService; private readonly IShipService _shipService; + private readonly IProductService _productService; - public ExcelService(IOrderService orderService, IShipService shipService) + public ExcelService(IOrderService orderService, IShipService shipService, IProductService productService) { _orderService = orderService; _shipService = shipService; + _productService = productService; } public async Task ExportPendingOrdersAsync(string filePath) { var orders = await _orderService.GetOrdersAsync(status: 1); + // 收集所有订单中的商品ID + var allGoodsIds = orders.SelectMany(o => o.GetGoodsIds()).Distinct().ToList(); + + // 批量查询货品编号 + var itemCodeMap = await _productService.GetItemCodesByGoodsIdsAsync(allGoodsIds); + using (var workbook = new XLWorkbook()) { var worksheet = workbook.Worksheets.Add("待发货订单"); @@ -31,7 +39,7 @@ namespace PackagingMallShipper.Services { "订单号", "下单时间", "收件人", "联系电话", "省份", "城市", "区县", "收货地址", - "商品信息", "数量", "快递公司", "快递单号" + "商品信息", "货品编号", "数量", "快递公司", "快递单号" }; for (int i = 0; i < headers.Length; i++) @@ -57,9 +65,10 @@ namespace PackagingMallShipper.Services worksheet.Cell(row, 7).Value = order.LogisticsDistrict; worksheet.Cell(row, 8).Value = order.FullAddress; worksheet.Cell(row, 9).Value = order.GoodsInfo; - worksheet.Cell(row, 10).Value = order.TotalQuantity; - worksheet.Cell(row, 11).Value = ""; + worksheet.Cell(row, 10).Value = order.GetItemCodesInfo(itemCodeMap); + worksheet.Cell(row, 11).Value = order.TotalQuantity; worksheet.Cell(row, 12).Value = ""; + worksheet.Cell(row, 13).Value = ""; } worksheet.Column(1).Width = 20; @@ -67,7 +76,8 @@ namespace PackagingMallShipper.Services worksheet.Column(4).Width = 15; worksheet.Column(8).Width = 50; worksheet.Column(9).Width = 30; - worksheet.Column(12).Width = 20; + worksheet.Column(10).Width = 40; + worksheet.Column(13).Width = 20; worksheet.SheetView.FreezeRows(1); @@ -92,8 +102,8 @@ namespace PackagingMallShipper.Services foreach (var row in rows) { var orderNumber = row.Cell(1).GetString().Trim(); - var expressCompanyName = row.Cell(11).GetString().Trim(); - var trackingNumber = row.Cell(12).GetString().Trim(); + var expressCompanyName = row.Cell(12).GetString().Trim(); + var trackingNumber = row.Cell(13).GetString().Trim(); if (string.IsNullOrEmpty(orderNumber)) continue; diff --git a/PackagingMallShipper/Services/Interfaces.cs b/PackagingMallShipper/Services/Interfaces.cs index 04348a7..c626fa0 100644 --- a/PackagingMallShipper/Services/Interfaces.cs +++ b/PackagingMallShipper/Services/Interfaces.cs @@ -46,4 +46,25 @@ namespace PackagingMallShipper.Services Task ExportPendingOrdersAsync(string filePath); Task ImportAndShipAsync(string filePath); } + + public interface IProductService + { + Task> GetProductsAsync(int? status = null, string keyword = null); + Task GetProductByIdAsync(int productId); + Task GetProductByItemCodeAsync(string itemCode); + Task GetProductByBarCodeAsync(string barCode); + Task SaveProductAsync(Product product); + Task DeleteProductAsync(int productId); + Task UpdateItemCodeAsync(int productId, string itemCode); + Task GetProductCountAsync(int? status = null); + Task BatchUpdateItemCodesAsync(Dictionary productItemCodes); + Task> GetItemCodesByGoodsIdsAsync(List goodsIds); + } + + public interface IProductSyncService + { + Task SyncProductsAsync(SyncMode mode = SyncMode.Incremental); + event Action OnSyncProgress; + event Action OnSyncMessage; + } } diff --git a/PackagingMallShipper/Services/ProductService.cs b/PackagingMallShipper/Services/ProductService.cs new file mode 100644 index 0000000..af9b782 --- /dev/null +++ b/PackagingMallShipper/Services/ProductService.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.Threading.Tasks; +using PackagingMallShipper.Data; +using PackagingMallShipper.Models; + +namespace PackagingMallShipper.Services +{ + public class ProductService : IProductService + { + public Task> GetProductsAsync(int? status = null, string keyword = null) + { + return Task.Run(() => + { + var products = new List(); + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "SELECT * FROM products_cache WHERE 1=1"; + + if (status.HasValue) + sql += " AND status = @status"; + + if (!string.IsNullOrWhiteSpace(keyword)) + sql += " AND (name LIKE @keyword OR bar_code LIKE @keyword OR yy_id LIKE @keyword OR item_code LIKE @keyword)"; + + sql += " ORDER BY local_updated_at DESC, synced_at DESC"; + + using (var cmd = new SQLiteCommand(sql, conn)) + { + if (status.HasValue) + cmd.Parameters.AddWithValue("@status", status.Value); + + if (!string.IsNullOrWhiteSpace(keyword)) + cmd.Parameters.AddWithValue("@keyword", $"%{keyword}%"); + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + products.Add(MapProduct(reader)); + } + } + } + } + return products; + }); + } + + public Task GetProductByIdAsync(int productId) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "SELECT * FROM products_cache WHERE id = @id"; + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@id", productId); + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + return MapProduct(reader); + } + } + } + return null; + }); + } + + public Task GetProductByItemCodeAsync(string itemCode) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "SELECT * FROM products_cache WHERE item_code = @itemCode"; + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@itemCode", itemCode); + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + return MapProduct(reader); + } + } + } + return null; + }); + } + + public Task GetProductByBarCodeAsync(string barCode) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "SELECT * FROM products_cache WHERE bar_code = @barCode"; + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@barCode", barCode); + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + return MapProduct(reader); + } + } + } + return null; + }); + } + + public Task SaveProductAsync(Product product) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + + // 检查是否存在 + var checkSql = "SELECT id FROM products_cache WHERE id = @id"; + bool exists = false; + using (var cmd = new SQLiteCommand(checkSql, conn)) + { + cmd.Parameters.AddWithValue("@id", product.Id); + exists = cmd.ExecuteScalar() != null; + } + + var sql = exists + ? @"UPDATE products_cache SET + name = @name, bar_code = @barCode, yy_id = @yyId, item_code = @itemCode, + min_price = @minPrice, original_price = @originalPrice, stores = @stores, + status = @status, category_id = @categoryId, unit = @unit, pics_json = @picsJson, + is_local_only = @isLocalOnly, local_note = @localNote, local_updated_at = @localUpdatedAt, + pickup_price = @pickupPrice, express_price = @expressPrice, pack_price = @packPrice, + pack_quantity = @packQuantity, product_series = @productSeries, + placement_method = @placementMethod, thickness = @thickness, hole_size = @holeSize, + box_size = @boxSize, applicable_type = @applicableType + WHERE id = @id" + : @"INSERT INTO products_cache + (id, name, bar_code, yy_id, item_code, min_price, original_price, stores, status, + category_id, unit, pics_json, is_local_only, local_note, local_created_at, local_updated_at, + pickup_price, express_price, pack_price, pack_quantity, product_series, + placement_method, thickness, hole_size, box_size, applicable_type) + VALUES + (@id, @name, @barCode, @yyId, @itemCode, @minPrice, @originalPrice, @stores, @status, + @categoryId, @unit, @picsJson, @isLocalOnly, @localNote, @localCreatedAt, @localUpdatedAt, + @pickupPrice, @expressPrice, @packPrice, @packQuantity, @productSeries, + @placementMethod, @thickness, @holeSize, @boxSize, @applicableType)"; + + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@id", product.Id); + cmd.Parameters.AddWithValue("@name", product.Name ?? ""); + cmd.Parameters.AddWithValue("@barCode", product.BarCode ?? ""); + cmd.Parameters.AddWithValue("@yyId", product.YyId ?? ""); + cmd.Parameters.AddWithValue("@itemCode", product.ItemCode ?? ""); + cmd.Parameters.AddWithValue("@minPrice", product.MinPrice); + cmd.Parameters.AddWithValue("@originalPrice", product.OriginalPrice); + cmd.Parameters.AddWithValue("@stores", product.Stores); + cmd.Parameters.AddWithValue("@status", product.Status); + cmd.Parameters.AddWithValue("@categoryId", (object)product.CategoryId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@unit", product.Unit ?? ""); + cmd.Parameters.AddWithValue("@picsJson", product.PicsJson ?? ""); + cmd.Parameters.AddWithValue("@isLocalOnly", product.IsLocalOnly ? 1 : 0); + cmd.Parameters.AddWithValue("@localNote", product.LocalNote ?? ""); + cmd.Parameters.AddWithValue("@localCreatedAt", product.LocalCreatedAt ?? DateTime.Now); + cmd.Parameters.AddWithValue("@localUpdatedAt", DateTime.Now); + cmd.Parameters.AddWithValue("@pickupPrice", (object)product.PickupPrice ?? DBNull.Value); + cmd.Parameters.AddWithValue("@expressPrice", (object)product.ExpressPrice ?? DBNull.Value); + cmd.Parameters.AddWithValue("@packPrice", (object)product.PackPrice ?? DBNull.Value); + cmd.Parameters.AddWithValue("@packQuantity", (object)product.PackQuantity ?? DBNull.Value); + cmd.Parameters.AddWithValue("@productSeries", product.ProductSeries ?? ""); + cmd.Parameters.AddWithValue("@placementMethod", product.PlacementMethod ?? ""); + cmd.Parameters.AddWithValue("@thickness", product.Thickness ?? ""); + cmd.Parameters.AddWithValue("@holeSize", product.HoleSize ?? ""); + cmd.Parameters.AddWithValue("@boxSize", product.BoxSize ?? ""); + cmd.Parameters.AddWithValue("@applicableType", product.ApplicableType ?? ""); + + return cmd.ExecuteNonQuery() > 0; + } + } + }); + } + + public Task DeleteProductAsync(int productId) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "DELETE FROM products_cache WHERE id = @id AND is_local_only = 1"; + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@id", productId); + return cmd.ExecuteNonQuery() > 0; + } + } + }); + } + + public Task UpdateItemCodeAsync(int productId, string itemCode) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "UPDATE products_cache SET item_code = @itemCode, local_updated_at = @now WHERE id = @id"; + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@id", productId); + cmd.Parameters.AddWithValue("@itemCode", itemCode ?? ""); + cmd.Parameters.AddWithValue("@now", DateTime.Now); + return cmd.ExecuteNonQuery() > 0; + } + } + }); + } + + public Task GetProductCountAsync(int? status = null) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var sql = "SELECT COUNT(*) FROM products_cache"; + if (status.HasValue) + sql += " WHERE status = @status"; + + using (var cmd = new SQLiteCommand(sql, conn)) + { + if (status.HasValue) + cmd.Parameters.AddWithValue("@status", status.Value); + + return Convert.ToInt32(cmd.ExecuteScalar()); + } + } + }); + } + + public Task BatchUpdateItemCodesAsync(Dictionary productItemCodes) + { + return Task.Run(() => + { + int updated = 0; + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + using (var transaction = conn.BeginTransaction()) + { + try + { + var sql = "UPDATE products_cache SET item_code = @itemCode, local_updated_at = @now WHERE id = @id"; + foreach (var kvp in productItemCodes) + { + using (var cmd = new SQLiteCommand(sql, conn, transaction)) + { + cmd.Parameters.AddWithValue("@id", kvp.Key); + cmd.Parameters.AddWithValue("@itemCode", kvp.Value ?? ""); + cmd.Parameters.AddWithValue("@now", DateTime.Now); + updated += cmd.ExecuteNonQuery(); + } + } + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + return updated; + }); + } + + public Task> GetItemCodesByGoodsIdsAsync(List goodsIds) + { + return Task.Run(() => + { + var result = new Dictionary(); + if (goodsIds == null || goodsIds.Count == 0) return result; + + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + var idList = string.Join(",", goodsIds); + var sql = $"SELECT id, item_code FROM products_cache WHERE id IN ({idList})"; + + using (var cmd = new SQLiteCommand(sql, conn)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var itemCode = reader.GetStringSafe("item_code"); + if (!string.IsNullOrEmpty(itemCode)) + { + result[id] = itemCode; + } + } + } + } + return result; + }); + } + + private Product MapProduct(SQLiteDataReader reader) + { + return new Product + { + Id = reader.GetInt32(reader.GetOrdinal("id")), + Name = reader.GetStringSafe("name"), + BarCode = reader.GetStringSafe("bar_code"), + YyId = reader.GetStringSafe("yy_id"), + ItemCode = reader.GetStringSafe("item_code"), + MinPrice = reader.GetDecimalSafe("min_price"), + OriginalPrice = reader.GetDecimalSafe("original_price"), + Stores = reader.GetInt32Safe("stores"), + Status = reader.GetInt32Safe("status"), + CategoryId = reader.GetInt32Nullable("category_id"), + Unit = reader.GetStringSafe("unit"), + PicsJson = reader.GetStringSafe("pics_json"), + IsLocalOnly = reader.GetInt32Safe("is_local_only") == 1, + LocalNote = reader.GetStringSafe("local_note"), + LocalCreatedAt = reader.GetDateTimeSafe("local_created_at"), + LocalUpdatedAt = reader.GetDateTimeSafe("local_updated_at"), + SyncedAt = reader.GetDateTimeSafe("synced_at"), + PickupPrice = reader.GetDecimalNullable("pickup_price"), + ExpressPrice = reader.GetDecimalNullable("express_price"), + PackPrice = reader.GetDecimalNullable("pack_price"), + PackQuantity = reader.GetInt32Nullable("pack_quantity"), + ProductSeries = reader.GetStringSafe("product_series"), + PlacementMethod = reader.GetStringSafe("placement_method"), + Thickness = reader.GetStringSafe("thickness"), + HoleSize = reader.GetStringSafe("hole_size"), + BoxSize = reader.GetStringSafe("box_size"), + ApplicableType = reader.GetStringSafe("applicable_type") + }; + } + } +} diff --git a/PackagingMallShipper/Services/ProductSyncService.cs b/PackagingMallShipper/Services/ProductSyncService.cs new file mode 100644 index 0000000..2237aa7 --- /dev/null +++ b/PackagingMallShipper/Services/ProductSyncService.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.Diagnostics; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using PackagingMallShipper.Data; +using PackagingMallShipper.Models; + +namespace PackagingMallShipper.Services +{ + public class ProductSyncService : IProductSyncService + { + private readonly IAuthService _authService; + private readonly HttpClient _httpClient; + + public event Action OnSyncProgress; + public event Action OnSyncMessage; + + public ProductSyncService(IAuthService authService) + { + _authService = authService; + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(60); + } + + public async Task SyncProductsAsync(SyncMode mode = SyncMode.Incremental) + { + var token = _authService.GetToken(); + if (string.IsNullOrEmpty(token)) + throw new UnauthorizedAccessException("未登录,请先登录"); + + var result = new SyncResult(); + + try + { + int page = 1; + int pageSize = 50; + bool hasMore = true; + int totalPages = 1; + + while (hasMore) + { + OnSyncMessage?.Invoke($"正在同步产品第 {page}/{totalPages} 页..."); + + var url = $"https://user.api.it120.cc/user/apiExtShopGoods/list?page={page}&pageSize={pageSize}"; + + Debug.WriteLine($"[产品同步] URL: {url}"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Token", token); + + var response = await _httpClient.PostAsync(url, null); + var json = await response.Content.ReadAsStringAsync(); + + Debug.WriteLine($"[产品同步] HTTP状态码: {response.StatusCode}"); + Debug.WriteLine($"[产品同步] 响应长度: {json?.Length ?? 0}"); + + var data = JsonConvert.DeserializeObject>(json); + + if (data?.Code != 0) + { + if (data?.Msg?.Contains("token") == true || data?.Msg?.Contains("登录") == true) + throw new UnauthorizedAccessException("当前登录token无效,请重新登录"); + throw new Exception(data?.Msg ?? "获取产品列表失败"); + } + + var products = data.Data?.GetProducts() ?? new List(); + totalPages = data.Data?.TotalPage ?? 1; + if (totalPages == 0) totalPages = 1; + var totalRow = data.Data?.GetTotalRow() ?? 0; + + Debug.WriteLine($"[产品同步] 产品数量: {products.Count}, 总记录: {totalRow}, 总页数: {totalPages}"); + + if (products.Count == 0) break; + + foreach (var productDto in products) + { + var isNew = await SaveProductAsync(productDto); + if (isNew) result.NewCount++; + else result.UpdatedCount++; + } + + result.TotalCount += products.Count; + OnSyncProgress?.Invoke(page, totalPages); + + if (page >= totalPages) hasMore = false; + else page++; + } + + OnSyncMessage?.Invoke($"产品同步完成!新增 {result.NewCount},更新 {result.UpdatedCount}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[产品同步异常] {ex.Message}"); + throw; + } + + return result; + } + + private Task SaveProductAsync(ProductDto dto) + { + return Task.Run(() => + { + using (var conn = SqliteHelper.GetConnection()) + { + conn.Open(); + + // 检查是否存在,并获取现有的本地字段 + string existingItemCode = null; + string existingLocalNote = null; + decimal? existingPickupPrice = null; + decimal? existingExpressPrice = null; + decimal? existingPackPrice = null; + int? existingPackQuantity = null; + string existingProductSeries = null; + string existingPlacementMethod = null; + string existingThickness = null; + string existingHoleSize = null; + string existingBoxSize = null; + string existingApplicableType = null; + bool exists = false; + + var checkSql = "SELECT * FROM products_cache WHERE id = @id"; + using (var cmd = new SQLiteCommand(checkSql, conn)) + { + cmd.Parameters.AddWithValue("@id", dto.Id); + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + { + exists = true; + existingItemCode = reader.GetStringSafe("item_code"); + existingLocalNote = reader.GetStringSafe("local_note"); + existingPickupPrice = reader.GetDecimalNullable("pickup_price"); + existingExpressPrice = reader.GetDecimalNullable("express_price"); + existingPackPrice = reader.GetDecimalNullable("pack_price"); + existingPackQuantity = reader.GetInt32Nullable("pack_quantity"); + existingProductSeries = reader.GetStringSafe("product_series"); + existingPlacementMethod = reader.GetStringSafe("placement_method"); + existingThickness = reader.GetStringSafe("thickness"); + existingHoleSize = reader.GetStringSafe("hole_size"); + existingBoxSize = reader.GetStringSafe("box_size"); + existingApplicableType = reader.GetStringSafe("applicable_type"); + } + } + } + + var isNew = !exists; + var picsJson = dto.Pics != null ? JsonConvert.SerializeObject(dto.Pics) : ""; + + var sql = isNew + ? @"INSERT INTO products_cache + (id, name, bar_code, yy_id, item_code, min_price, original_price, stores, status, + category_id, unit, pics_json, is_local_only, synced_at, local_created_at) + VALUES + (@id, @name, @barCode, @yyId, @itemCode, @minPrice, @originalPrice, @stores, @status, + @categoryId, @unit, @picsJson, 0, @syncedAt, @syncedAt)" + : @"UPDATE products_cache SET + name = @name, bar_code = @barCode, yy_id = @yyId, + min_price = @minPrice, original_price = @originalPrice, stores = @stores, + status = @status, category_id = @categoryId, unit = @unit, pics_json = @picsJson, + synced_at = @syncedAt + WHERE id = @id"; + + using (var cmd = new SQLiteCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@id", dto.Id); + cmd.Parameters.AddWithValue("@name", dto.Name ?? ""); + cmd.Parameters.AddWithValue("@barCode", dto.BarCode ?? ""); + cmd.Parameters.AddWithValue("@yyId", dto.YyId ?? ""); + cmd.Parameters.AddWithValue("@itemCode", existingItemCode ?? ""); + cmd.Parameters.AddWithValue("@minPrice", dto.MinPrice); + cmd.Parameters.AddWithValue("@originalPrice", dto.OriginalPrice); + cmd.Parameters.AddWithValue("@stores", dto.Stores); + cmd.Parameters.AddWithValue("@status", dto.Status); + cmd.Parameters.AddWithValue("@categoryId", (object)dto.CategoryId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@unit", dto.Unit ?? ""); + cmd.Parameters.AddWithValue("@picsJson", picsJson); + cmd.Parameters.AddWithValue("@syncedAt", DateTime.Now); + cmd.ExecuteNonQuery(); + } + + return isNew; + } + }); + } + } +} diff --git a/PackagingMallShipper/ViewModels/MainViewModel.cs b/PackagingMallShipper/ViewModels/MainViewModel.cs index 6c43d0e..9ec106e 100644 --- a/PackagingMallShipper/ViewModels/MainViewModel.cs +++ b/PackagingMallShipper/ViewModels/MainViewModel.cs @@ -15,12 +15,22 @@ namespace PackagingMallShipper.ViewModels [ObservableProperty] private OrderListViewModel _orderListViewModel; + [ObservableProperty] + private ProductListViewModel _productListViewModel; + + [ObservableProperty] + private int _selectedTabIndex = 0; + public event Action OnLogout; - public MainViewModel(IAuthService authService, OrderListViewModel orderListViewModel) + public MainViewModel( + IAuthService authService, + OrderListViewModel orderListViewModel, + ProductListViewModel productListViewModel) { _authService = authService; _orderListViewModel = orderListViewModel; + _productListViewModel = productListViewModel; if (_authService.CurrentSession != null) { diff --git a/PackagingMallShipper/ViewModels/ProductListViewModel.cs b/PackagingMallShipper/ViewModels/ProductListViewModel.cs new file mode 100644 index 0000000..ddc6322 --- /dev/null +++ b/PackagingMallShipper/ViewModels/ProductListViewModel.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PackagingMallShipper.Models; +using PackagingMallShipper.Services; + +namespace PackagingMallShipper.ViewModels +{ + public partial class ProductListViewModel : ViewModelBase + { + private readonly IProductService _productService; + private readonly IProductSyncService _productSyncService; + + [ObservableProperty] + private ObservableCollection _products = new ObservableCollection(); + + [ObservableProperty] + private Product _selectedProduct; + + [ObservableProperty] + private int _selectedStatusIndex = 0; // 0:全部, 1:上架, 2:下架 + + [ObservableProperty] + private string _searchText = ""; + + [ObservableProperty] + private int _totalCount; + + [ObservableProperty] + private int _onSaleCount; + + [ObservableProperty] + private string _syncProgress = ""; + + [ObservableProperty] + private DateTime? _lastSyncTime; + + [ObservableProperty] + private bool _isEditing; + + [ObservableProperty] + private string _editingItemCode = ""; + + public ProductListViewModel(IProductService productService, IProductSyncService productSyncService) + { + _productService = productService; + _productSyncService = productSyncService; + + _productSyncService.OnSyncProgress += (current, total) => + { + SyncProgress = $"同步中 {current}/{total}"; + }; + + _productSyncService.OnSyncMessage += (msg) => + { + StatusMessage = msg; + }; + } + + public async Task InitializeAsync() + { + await RefreshProductsAsync(); + await UpdateCountsAsync(); + } + + [RelayCommand] + private async Task RefreshProductsAsync() + { + IsBusy = true; + StatusMessage = "加载中..."; + + try + { + int? status = SelectedStatusIndex switch + { + 1 => ProductStatus.OnSale, + 2 => ProductStatus.OffSale, + _ => null + }; + + var products = await _productService.GetProductsAsync(status, SearchText); + + Products.Clear(); + foreach (var product in products) + { + Products.Add(product); + } + + TotalCount = products.Count; + StatusMessage = $"共 {TotalCount} 个产品"; + } + catch (Exception ex) + { + StatusMessage = $"加载失败: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task SyncProductsAsync() + { + IsBusy = true; + StatusMessage = "正在同步产品..."; + + try + { + var result = await _productSyncService.SyncProductsAsync(SyncMode.Full); + LastSyncTime = DateTime.Now; + await RefreshProductsAsync(); + await UpdateCountsAsync(); + + StatusMessage = $"同步完成!新增 {result.NewCount},更新 {result.UpdatedCount}"; + } + catch (UnauthorizedAccessException) + { + StatusMessage = "登录已过期,请重新登录"; + MessageBox.Show("登录已过期,请重新登录", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + StatusMessage = $"同步失败: {ex.Message}"; + MessageBox.Show($"同步失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + SyncProgress = ""; + } + } + + [RelayCommand] + private async Task SearchAsync() + { + await RefreshProductsAsync(); + } + + [RelayCommand] + private void StartEditItemCode() + { + if (SelectedProduct == null) + { + MessageBox.Show("请先选择一个产品", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + EditingItemCode = SelectedProduct.ItemCode ?? ""; + IsEditing = true; + } + + [RelayCommand] + private async Task SaveItemCodeAsync() + { + if (SelectedProduct == null) return; + + try + { + await _productService.UpdateItemCodeAsync(SelectedProduct.Id, EditingItemCode); + SelectedProduct.ItemCode = EditingItemCode; + StatusMessage = "货品编号已更新"; + + IsEditing = false; + EditingItemCode = ""; + + await RefreshProductsAsync(); + } + catch (Exception ex) + { + MessageBox.Show($"更新失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + [RelayCommand] + private void CancelEditItemCode() + { + IsEditing = false; + EditingItemCode = ""; + } + + [RelayCommand] + private async Task DeleteProductAsync() + { + if (SelectedProduct == null) return; + + if (!SelectedProduct.IsLocalOnly) + { + MessageBox.Show("只能删除本地创建的产品,不能删除从API同步的产品", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var result = MessageBox.Show($"确定要删除产品 [{SelectedProduct.Name}] 吗?", "确认删除", + MessageBoxButton.YesNo, MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) return; + + try + { + await _productService.DeleteProductAsync(SelectedProduct.Id); + await RefreshProductsAsync(); + await UpdateCountsAsync(); + StatusMessage = "删除成功"; + } + catch (Exception ex) + { + MessageBox.Show($"删除失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private async Task UpdateCountsAsync() + { + try + { + TotalCount = await _productService.GetProductCountAsync(); + OnSaleCount = await _productService.GetProductCountAsync(ProductStatus.OnSale); + } + catch + { + // Ignore + } + } + + partial void OnSelectedStatusIndexChanged(int value) + { + _ = RefreshProductsAsync(); + } + + partial void OnSearchTextChanged(string value) + { + // 可添加防抖逻辑 + } + } +} diff --git a/PackagingMallShipper/Views/LoginWindow.xaml.cs b/PackagingMallShipper/Views/LoginWindow.xaml.cs index f766be9..b461484 100644 --- a/PackagingMallShipper/Views/LoginWindow.xaml.cs +++ b/PackagingMallShipper/Views/LoginWindow.xaml.cs @@ -49,9 +49,14 @@ namespace PackagingMallShipper.Views var orderService = new OrderService(); var syncService = new SyncService(_authService); var shipService = new ShipService(_authService); - var excelService = new ExcelService(orderService, shipService); + var productService = new ProductService(); + var excelService = new ExcelService(orderService, shipService, productService); var orderListViewModel = new OrderListViewModel(orderService, syncService, shipService, excelService); - var mainViewModel = new MainViewModel(_authService, orderListViewModel); + + var productSyncService = new ProductSyncService(_authService); + var productListViewModel = new ProductListViewModel(productService, productSyncService); + + var mainViewModel = new MainViewModel(_authService, orderListViewModel, productListViewModel); var mainWindow = new MainWindow(mainViewModel); mainViewModel.OnLogout += () => diff --git a/PackagingMallShipper/Views/MainWindow.xaml b/PackagingMallShipper/Views/MainWindow.xaml index 420d2a5..a69428c 100644 --- a/PackagingMallShipper/Views/MainWindow.xaml +++ b/PackagingMallShipper/Views/MainWindow.xaml @@ -5,32 +5,32 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:PackagingMallShipper.Views" mc:Ignorable="d" - Title="包装商城发货助手" + Title="包装商城发货助手" Height="700" Width="1100" WindowStartupLocation="CenterScreen" MinHeight="500" MinWidth="800"> - + - + - - + - -