diff --git a/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0073_add_parent_id_to_phone_order_record.sql b/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0073_add_parent_id_to_phone_order_record.sql new file mode 100644 index 000000000..92f64648e --- /dev/null +++ b/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0073_add_parent_id_to_phone_order_record.sql @@ -0,0 +1,2 @@ +alter table `phone_order_record` add column `parent_record_id` int null; +alter table `phone_order_record` add column `is_completed` tinyint(1) not null default 0; \ No newline at end of file diff --git a/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0074_add_phone_order_push_task.sql b/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0074_add_phone_order_push_task.sql new file mode 100644 index 000000000..77e221234 --- /dev/null +++ b/src/SmartTalk.Core/DbUpFile/Scripts_2025/Script0074_add_phone_order_push_task.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `phone_order_push_task` +( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `record_id` INT NOT NULL, + `parent_record_id` INT NULL, + `assistant_id` INT NOT NULL, + `business_key` VARCHAR(128) NOT NULL, + `task_type` INT NOT NULL, + `request_json` LONGTEXT NOT NULL, + `status` INT NOT NULL, + `created_at` DATETIME(3) NOT NULL + ) CHARSET = utf8mb4; + +CREATE INDEX idx_record_id ON phone_order_push_task(record_id); + +CREATE INDEX idx_parent_record_id ON phone_order_push_task(parent_record_id); + +CREATE UNIQUE INDEX uk_record_business ON phone_order_push_task(record_id, business_key); \ No newline at end of file diff --git a/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs b/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs index fdda25a5f..0b7648ea7 100644 --- a/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs +++ b/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs @@ -110,4 +110,10 @@ public class PhoneOrderRecord : IEntity [NotMapped] public Restaurant RestaurantInfo { get; set; } + + [Column("parent_record_id")] + public int? ParentRecordId { get; set; } + + [Column("is_completed")] + public bool IsCompleted { get; set; } = false; } \ No newline at end of file diff --git a/src/SmartTalk.Core/Domain/Sales/PhoneOrderPushTask.cs b/src/SmartTalk.Core/Domain/Sales/PhoneOrderPushTask.cs new file mode 100644 index 000000000..8489247e8 --- /dev/null +++ b/src/SmartTalk.Core/Domain/Sales/PhoneOrderPushTask.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using SmartTalk.Messages.Enums.Sales; + +namespace SmartTalk.Core.Domain.Sales; + +[Table("phone_order_push_task")] +public class PhoneOrderPushTask : IEntity +{ + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + [Column("record_id")] + public int RecordId { get; set; } + + [Column("parent_record_id")] + public int? ParentRecordId { get; set; } + + [Column("assistant_id")] + public int AssistantId { get; set; } + + [Column("business_key"), StringLength(128)] + public string BusinessKey { get; set; } + + [Column("task_type")] + public PhoneOrderPushTaskType TaskType { get; set; } + + [Column("request_json")] + public string RequestJson { get; set; } + + [Column("status")] + public PhoneOrderPushTaskStatus Status { get; set; } + + [Column("created_at")] + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; +} diff --git a/src/SmartTalk.Core/Services/AiSpeechAssistant/AiSpeechAssistantProcessJobService.cs b/src/SmartTalk.Core/Services/AiSpeechAssistant/AiSpeechAssistantProcessJobService.cs index f9172f565..975c8f9a0 100644 --- a/src/SmartTalk.Core/Services/AiSpeechAssistant/AiSpeechAssistantProcessJobService.cs +++ b/src/SmartTalk.Core/Services/AiSpeechAssistant/AiSpeechAssistantProcessJobService.cs @@ -95,6 +95,8 @@ public async Task RecordAiSpeechAssistantCallAsync(AiSpeechAssistantStreamContex if (agentAssistant == null || agentAssistant.Count == 0) throw new Exception("AgentAssistant is null"); + var parentRecordId = await _phoneOrderDataProvider.GetLatestPhoneOrderRecordIdAsync(agentAssistant.First().AgentId, context.Assistant.Id, context.CallSid, cancellationToken).ConfigureAwait(false); + var record = new PhoneOrderRecord { AssistantId = context.Assistant.Id, @@ -110,7 +112,8 @@ public async Task RecordAiSpeechAssistantCallAsync(AiSpeechAssistantStreamContex PhoneNumber = context.UserInfo?.PhoneNumber, IsTransfer = context.IsTransfer, IncomingCallNumber = context.LastUserInfo.PhoneNumber, - OrderRecordType = orderRecordType + OrderRecordType = orderRecordType, + ParentRecordId = parentRecordId }; await _phoneOrderDataProvider.AddPhoneOrderRecordsAsync([record], cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs index 47bd631c2..d7a08c2a9 100644 --- a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs +++ b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs @@ -18,6 +18,8 @@ public interface ISalesClient : IScopedDependency Task GetCustomerNumbersByNameAsync(GetCustomerNumbersByNameRequestDto request, CancellationToken cancellationToken); Task GetCustomerLevel5HabitAsync(GetCustomerLevel5HabitRequstDto request, CancellationToken cancellationToken); + + Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken); } public class SalesClient : ISalesClient @@ -103,4 +105,9 @@ public async Task GetCustomerLevel5HabitAsync return await _httpClientFactory.PostAsJsonAsync($"{_salesCustomerHabitSetting.BaseUrl}/api/CustomerInfo/QueryHistoryCustomerLevel5Habit", request, headers: header, cancellationToken: cancellationToken).ConfigureAwait(false); } + + public async Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken) + { + return await _httpClientFactory.PostAsJsonAsync($"{_salesSetting.BaseUrl}/api/SalesOrder/DeleteAiOrder", request, headers: _headers, cancellationToken: cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderDataProvider.Record.cs b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderDataProvider.Record.cs index 7113e1a11..937a1934e 100644 --- a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderDataProvider.Record.cs +++ b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderDataProvider.Record.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using Serilog; using SmartTalk.Core.Domain.Account; using SmartTalk.Core.Domain.AISpeechAssistant; @@ -6,12 +7,14 @@ using SmartTalk.Core.Domain.Pos; using SmartTalk.Core.Domain.Printer; using SmartTalk.Core.Domain.Restaurants; +using SmartTalk.Core.Domain.Sales; using SmartTalk.Core.Domain.System; using SmartTalk.Messages.Dto.Agent; using SmartTalk.Messages.Dto.PhoneOrder; using SmartTalk.Messages.Dto.Restaurant; using SmartTalk.Messages.Enums; using SmartTalk.Messages.Enums.PhoneOrder; +using SmartTalk.Messages.Enums.Sales; using SmartTalk.Messages.Enums.Pos; using SmartTalk.Messages.Enums.STT; @@ -69,6 +72,12 @@ Task> GetPhoneOrderRecordsAsync( Task> GetPhoneOrderRecordReportByRecordIdAsync(List recordId, CancellationToken cancellationToken); Task UpdatePhoneOrderRecordReportsAsync(List reports, bool forceSave = true, CancellationToken cancellationToken = default); + + Task GetLatestPhoneOrderRecordIdAsync(int agentId, int assistantId, string currentSessionId, CancellationToken cancellationToken); + + Task UpdateOrderIdAsync(int recordId, Guid orderId, CancellationToken cancellationToken); + + Task MarkRecordCompletedAsync(int recordId, CancellationToken cancellationToken = default); Task AddPhoneOrderRecordScenarioHistoryAsync(PhoneOrderRecordScenarioHistory scenarioHistory, bool forceSave = true, CancellationToken cancellationToken = default); @@ -110,29 +119,33 @@ public async Task AddPhoneOrderRecordsAsync(List phoneOrderRec if (forceSave) await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - + public async Task> GetPhoneOrderRecordsAsync( - List agentIds, string name, DateTimeOffset? utcStart = null, DateTimeOffset? utcEnd = null, string orderId = null, - List scenarios = null, int? assistantId = null, CancellationToken cancellationToken = default) + List agentIds, string name, DateTimeOffset? utcStart = null, DateTimeOffset? utcEnd = null, + string orderId = null, + List scenarios = null, int? assistantId = null, + CancellationToken cancellationToken = default) { var agentsQuery = from agent in _repository.Query() - join agentAssistant in _repository.Query() on agent.Id equals agentAssistant.AgentId into agentAssistantGroups + join agentAssistant in _repository.Query() on agent.Id equals agentAssistant.AgentId into + agentAssistantGroups from agentAssistant in agentAssistantGroups.DefaultIfEmpty() - join assistant in _repository.Query() on agentAssistant.AssistantId equals assistant.Id into assistantGroups + join assistant in _repository.Query() on + agentAssistant.AssistantId equals assistant.Id into assistantGroups from assistant in assistantGroups.DefaultIfEmpty() - where (agentIds == null || !agentIds.Any() || agentIds.Contains(agent.Id)) && (string.IsNullOrEmpty(name) || assistant == null || assistant.Name.Contains(name)) + where (agentIds == null || !agentIds.Any() || agentIds.Contains(agent.Id)) && + (string.IsNullOrEmpty(name) || assistant == null || assistant.Name.Contains(name)) select agent; - Log.Information("GetPhoneOrderRecordsAsync: agentIds: {@agentIds}", agentIds); - + var agents = (await agentsQuery.ToListAsync(cancellationToken).ConfigureAwait(false)).Select(x => x.Id).Distinct().ToList(); if (agents.Count == 0) return []; - + var query = from record in _repository.Query() where record.Status == PhoneOrderRecordStatus.Sent && agents.Contains(record.AgentId) select record; - + Log.Information("GetPhoneOrderRecordsAsync: recordCount: {@RecordCount}", query.Count()); if (scenarios is { Count: > 0 }) @@ -140,17 +153,18 @@ from assistant in assistantGroups.DefaultIfEmpty() var scenarioInts = scenarios.Select(s => (int)s).ToList(); query = query.Where(r => r.Scenario.HasValue && scenarioInts.Contains((int)r.Scenario.Value)); } - + if (utcStart.HasValue && utcEnd.HasValue) query = query.Where(record => record.CreatedDate >= utcStart.Value && record.CreatedDate < utcEnd.Value); - + if (!string.IsNullOrEmpty(orderId)) query = query.Where(record => record.OrderId.Contains(orderId)); if (assistantId.HasValue) query = query.Where(x => x.AssistantId.HasValue && x.AssistantId == assistantId.Value); - return await query.OrderByDescending(record => record.CreatedDate).Take(1000).ToListAsync(cancellationToken).ConfigureAwait(false); + return await query.OrderByDescending(record => record.CreatedDate).Take(1000).ToListAsync(cancellationToken) + .ConfigureAwait(false); } public async Task> GetPhoneOrderRecordsByAgentIdsAsync(List agentIds, DateTimeOffset? utcStart = null, DateTimeOffset? utcEnd = null, CancellationToken cancellationToken = default) @@ -213,6 +227,18 @@ public async Task> GetLatestPhoneOrderRecordsB public async Task UpdatePhoneOrderRecordsAsync(PhoneOrderRecord record, bool forceSave = true, CancellationToken cancellationToken = default) { + var existing = await _repository.Query() + .Where(r => r.Id == record.Id) + .Select(r => new { r.IsCompleted, r.OrderId }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (existing != null) + { + record.IsCompleted = existing.IsCompleted; + record.OrderId = existing.OrderId; + } + await _repository.UpdateAsync(record, cancellationToken).ConfigureAwait(false); if (forceSave) await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); @@ -444,7 +470,55 @@ public async Task UpdatePhoneOrderRecordReportsAsync(List GetLatestPhoneOrderRecordIdAsync(int agentId, int assistantId, string currentSessionId, CancellationToken cancellationToken) + { + var records = await _repository.Query().Where(r => r.AgentId == agentId && r.AssistantId == assistantId && r.SessionId != currentSessionId) + .OrderByDescending(r => r.CreatedDate).ThenByDescending(r => r.Id).Select(r => r.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + + foreach (var recordId in records) + { + if (await IsRecordCompletedAsync(recordId, cancellationToken).ConfigureAwait(false)) + return recordId; + } + + return null; + } + + public async Task UpdateOrderIdAsync(int recordId, Guid orderId, CancellationToken cancellationToken) + { + var record = await _repository.Query().Where(r => r.Id == recordId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (record == null) return; + + var orderIds = string.IsNullOrEmpty(record.OrderId) ? new List() : JsonConvert.DeserializeObject>(record.OrderId)!; + + orderIds.Add(orderId); + record.OrderId = JsonConvert.SerializeObject(orderIds); + await _repository.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task IsRecordCompletedAsync(int recordId, CancellationToken cancellationToken) + { + var tasks = await _repository.Query() + .Where(t => t.RecordId == recordId) + .Select(t => t.Status) + .ToListAsync(cancellationToken); + + if (!tasks.Any()) + return true; + + return tasks.All(s => s == PhoneOrderPushTaskStatus.Sent); + } + + public async Task MarkRecordCompletedAsync(int recordId, CancellationToken cancellationToken = default) + { + await _repository.Query().Where(r => r.Id == recordId && !r.IsCompleted) + .ExecuteUpdateAsync(setters => setters.SetProperty(r => r.IsCompleted, true), cancellationToken).ConfigureAwait(false); + } + public async Task AddPhoneOrderRecordScenarioHistoryAsync(PhoneOrderRecordScenarioHistory scenarioHistory, bool forceSave = true, CancellationToken cancellationToken = default) { await _repository.InsertAsync(scenarioHistory, cancellationToken).ConfigureAwait(false); @@ -637,4 +711,4 @@ public async Task GetOriginalPhoneOrderRecordReportAsync { return await _repository.Query().Where(x => x.RecordId == recordId && x.IsOrigin).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); } -} +} \ No newline at end of file diff --git a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs index 90636356e..9c1e97dbc 100644 --- a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs +++ b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs @@ -26,6 +26,8 @@ using JsonDocument = System.Text.Json.JsonDocument; using JsonSerializer = System.Text.Json.JsonSerializer; using System.ClientModel; +using SmartTalk.Core.Domain.Sales; +using SmartTalk.Messages.Enums.Sales; namespace SmartTalk.Core.Services.PhoneOrder; @@ -420,35 +422,30 @@ await HandleSalesScenarioAsync(agent, aiSpeechAssistant, record, cancellationTok } } - private async Task HandleSalesScenarioAsync(Agent agent, - Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, PhoneOrderRecord record, - CancellationToken cancellationToken) - { + private async Task HandleSalesScenarioAsync(Agent agent, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, PhoneOrderRecord record, CancellationToken cancellationToken) + { if (string.IsNullOrEmpty(record.TranscriptionText)) return; - var soldToIds = new List(); + var soldToIds = new List(); if (!string.IsNullOrEmpty(aiSpeechAssistant.Name)) - soldToIds = aiSpeechAssistant.Name.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList(); + soldToIds = aiSpeechAssistant.Name.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList(); - var historyItems = await GetCustomerHistoryItemsBySoldToIdAsync(soldToIds, cancellationToken) - .ConfigureAwait(false); + var historyItems = await GetCustomerHistoryItemsBySoldToIdAsync(soldToIds, cancellationToken).ConfigureAwait(false); - var extractedOrders = - await ExtractAndMatchOrderItemsFromReportAsync(record.TranscriptionText, historyItems, cancellationToken) - .ConfigureAwait(false); + var extractedOrders = await ExtractAndMatchOrderItemsFromReportAsync(record.TranscriptionText, historyItems, cancellationToken).ConfigureAwait(false); if (!extractedOrders.Any()) return; var pacificZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); var pacificNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, pacificZone); foreach (var storeOrder in extractedOrders) - { + { var soldToId = await ResolveSoldToIdAsync(storeOrder, aiSpeechAssistant, soldToIds, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(soldToId)) + + if (storeOrder.IsDeleteWholeOrder && !storeOrder.Orders.Any()) { - Log.Warning("未能获取店铺 SoldToId, StoreName={StoreName}, StoreNumber={StoreNumber}", storeOrder.StoreName, - storeOrder.StoreNumber); + await CreateDeleteOrderTaskAsync(record, storeOrder, soldToId, soldToIds, pacificZone, pacificNow, cancellationToken).ConfigureAwait(false); + continue; } foreach (var item in storeOrder.Orders) @@ -456,41 +453,70 @@ await ExtractAndMatchOrderItemsFromReportAsync(record.TranscriptionText, history item.MaterialNumber = MatchMaterialNumber(item.Name, item.MaterialNumber, item.Unit, historyItems); } - var draftOrder = CreateDraftOrder(storeOrder, soldToId, aiSpeechAssistant, pacificZone, pacificNow); - Log.Information("DraftOrder for Store {StoreName}/{StoreNumber}: {@DraftOrder}", storeOrder.StoreName, - storeOrder.StoreNumber, draftOrder); + var draftOrder = CreateDraftOrder(storeOrder, soldToId, aiSpeechAssistant, pacificZone, pacificNow, storeOrder.IsUndoCancel); - var response = await _salesClient.GenerateAiOrdersAsync(draftOrder, cancellationToken).ConfigureAwait(false); - Log.Information("Generate Ai Order response for Store {StoreName}/{StoreNumber}: {@response}", - storeOrder.StoreName, storeOrder.StoreNumber, response); - - if (response?.Data != null && response.Data.OrderId != Guid.Empty) - { - await UpdateRecordOrderIdAsync(record, response.Data.OrderId, cancellationToken).ConfigureAwait(false); - } + await CreateGenerateOrderTaskAsync(record, storeOrder, draftOrder, cancellationToken).ConfigureAwait(false); } } - private async Task> ExtractAndMatchOrderItemsFromReportAsync(string reportText, - List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems, - CancellationToken cancellationToken) - { + private async Task> ExtractAndMatchOrderItemsFromReportAsync(string reportText, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems, CancellationToken cancellationToken) + { var client = new ChatClient("gpt-4.1", _openAiSettings.ApiKey); - var materialListText = string.Join("\n", - historyItems.Select(x => $"{x.MaterialDesc} ({x.Material})【{x.invoiceDate}】")); + var materialListText = string.Join("\n", historyItems.Select(x => $"{x.MaterialDesc} ({x.Material})【{x.invoiceDate}】")); var systemPrompt = - "你是一名訂單分析助手。請從下面的客戶分析報告文字中提取所有下單的物料名稱、數量、單位,並且用歷史物料列表盡力匹配每個物料的materialNumber。「優先匹配物料列表中靠前的物料,如果有多個同類物料,選用歷史列表中排在前的。」" + - "如果報告中提到了預約送貨時間,請提取送貨時間(格式yyyy-MM-dd)。" + - "如果客戶提到了分店名,請提取 StoreName;如果提到第幾家店,請提取 StoreNumber。\n" + - "請嚴格傳回一個 JSON 對象,頂層字段為 \"stores\",每个店铺对象包含:StoreName(可空字符串), StoreNumber(可空字符串), DeliveryDate(可空字符串),orders(数组,元素包含 name, quantity, unit, materialNumber, deliveryDate)。\n" + - "範例:\n" + - "{\n \"stores\": [\n {\n \"StoreName\": \"HaiDiLao\",\n \"StoreNumber\": \"1\",\n \"DeliveryDate\": \"2025-08-20\",\n \"orders\": [\n {\n \"name\": \"雞胸肉\",\n \"quantity\": 1,\n \"unit\": \"箱\",\n \"materialNumber\": \"000000000010010253\"\n }\n ]\n }\n ]\n}" + - "歷史物料列表:\n" + materialListText + "\n\n" + - "每個物料的格式為「物料名稱(物料號碼)」,部分物料會包含日期\n 當有多個相似的物料名稱時,請根據以下規則選擇匹配的物料號碼:1. **優先選擇沒有日期的物料。**\n 2. 如果所有相似物料都有日期,請選擇日期**最新** 的那個物料。\n\n " + - "注意:\n1. 必須嚴格輸出 JSON,物件頂層字段必須是 \"stores\",不要有其他字段或額外說明。\n2. 提取的物料名稱需要為繁體中文。\n3. 如果没有提到店铺信息,但是有下单内容,则StoreName和StoreNumber可为空值,orders要正常提取。\n4. **如果客戶分析文本中沒有任何可識別的下單信息,請返回:{ \"stores\": [] }。不得臆造或猜測物料。** \n" + - "請務必完整提取報告中每一個提到的物料"; + "你是一名訂單分析助手。請從下面的客戶分析報告文字中提取所有下單的物料名稱、數量、單位,並且用歷史物料列表盡力匹配每個物料的materialNumber。" + + "如果報告中提到了預約送貨時間,請提取送貨時間(格式yyyy-MM-dd)。" + + "如果客戶提到了分店名,請提取 StoreName;如果提到第幾家店,請提取 StoreNumber。\n" + + + "【訂單意圖判斷規則(非常重要)】\n" + + "1. 如果客戶明確表示取消整張訂單、全部不要、整單取消、今天的單都不要,請在該店鋪標記 IsDeleteWholeOrder=true,orders 可以為空陣列。\n" + + "2. 如果客戶先說取消整單,後面又表示還是要、算了繼續下單、剛剛的取消不算,請標記 IsUndoCancel=true。\n" + + "3. 如果客戶只取消單個物料(例如:某某不要了、某某取消、某某 cut 掉),請保留該物料,並在該物料上標記 markForDelete=true,有提到數量的話 quantity 需要用負數表示\n" + + "4. 單個物料取消不等於取消整單,IsDeleteWholeOrder = false。\n" + + "5. 如果是減少某個物料的數量,請在該物料的 quantity 使用負數表示,並要使用 markForDelete = true。\n\n" + + + "請嚴格傳回一個 JSON 對象,頂層字段為 \"stores\",每个店铺对象包含:" + + "StoreName(可空字符串), StoreNumber(可空字符串), DeliveryDate(可空字符串), " + + "IsDeleteWholeOrder(boolean,默認 false), IsUndoCancel(boolean,默認 false), " + + "orders(数组,元素包含 name, quantity, unit, materialNumber, markForDelete)。\n" + + + "範例:\n" + + "{\n" + + " \"stores\": [\n" + + " {\n" + + " \"StoreName\": \"HaiDiLao\",\n" + + " \"StoreNumber\": \"1\",\n" + + " \"DeliveryDate\": \"2025-08-20\",\n" + + " \"IsDeleteWholeOrder\": false,\n" + + " \"IsUndoCancel\": false,\n" + + " \"orders\": [\n" + + " {\n" + + " \"name\": \"雞胸肉\",\n" + + " \"quantity\": 1,\n" + + " \"unit\": \"箱\",\n" + + " \"materialNumber\": \"000000000010010253\",\n" + + " \"markForDelete\": false\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}\n" + + + "歷史物料列表:\n" + materialListText + "\n\n" + + + "每個物料的格式為「物料名稱(物料號碼)」,部分物料會包含日期。\n" + + "當有多個相似的物料名稱時,請根據以下規則選擇匹配的物料號碼:\n" + + "1. **優先選擇沒有日期的物料。**\n" + + "2. 如果所有相似物料都有日期,請選擇日期 **最新** 的那個物料。\n\n" + + + "注意:\n" + + "1. 必須嚴格輸出 JSON,物件頂層字段必須是 \"stores\",不要有其他字段或額外說明。\n" + + "2. 提取的物料名稱需要為繁體中文。\n" + + "3. 如果沒有提到店鋪信息,但有下單內容,StoreName 和 StoreNumber 可為空值,orders 要正常提取。\n" + + "4. **如果客戶分析文本中沒有任何可識別的下單信息,請返回:{ \"stores\": [] }。不得臆造或猜測物料。**\n" + + "5. 請務必完整提取報告中每一個提到的物料,如果你不知道它的materialNumber,那也必須保留該物料的quantity以及name。"; Log.Information("Sending prompt to GPT: {Prompt}", systemPrompt); var messages = new List @@ -499,18 +525,13 @@ private async Task> ExtractAndMatchOrderItemsFromReportA new UserChatMessage("客戶分析報告文本:\n" + reportText + "\n\n") }; - var completion = await client.CompleteChatAsync(messages, - new ChatCompletionOptions - { - ResponseModalities = ChatResponseModalities.Text, - ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat() - }, cancellationToken).ConfigureAwait(false); + var completion = await client.CompleteChatAsync(messages, new ChatCompletionOptions { ResponseModalities = ChatResponseModalities.Text, ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat() }, cancellationToken).ConfigureAwait(false); var jsonResponse = completion.Value.Content.FirstOrDefault()?.Text ?? ""; - + Log.Information("AI JSON Response: {JsonResponse}", jsonResponse); - try - { + try + { using var jsonDoc = JsonDocument.Parse(jsonResponse); var storesArray = jsonDoc.RootElement.GetProperty("stores"); @@ -521,153 +542,126 @@ private async Task> ExtractAndMatchOrderItemsFromReportA var storeDto = new ExtractedOrderDto { StoreName = storeElement.TryGetProperty("StoreName", out var sn) ? sn.GetString() ?? "" : "", - StoreNumber = - storeElement.TryGetProperty("StoreNumber", out var snum) ? snum.GetString() ?? "" : "", - DeliveryDate = - storeElement.TryGetProperty("DeliveryDate", out var dd) && - DateTime.TryParse(dd.GetString(), out var dt) - ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) - : DateTime.UtcNow.AddDays(1) - }; - - if (storeElement.TryGetProperty("orders", out var ordersArray)) - { - foreach (var orderItem in ordersArray.EnumerateArray()) - { - var name = orderItem.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; - var qty = orderItem.TryGetProperty("quantity", out var q) && q.TryGetDecimal(out var dec) - ? dec - : 0; - var unit = orderItem.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : ""; - var materialNumber = orderItem.TryGetProperty("materialNumber", out var mn) - ? mn.GetString() ?? "" - : ""; + StoreNumber = storeElement.TryGetProperty("StoreNumber", out var snum) ? snum.GetString() ?? "" : "", + DeliveryDate = storeElement.TryGetProperty("DeliveryDate", out var dd) && DateTime.TryParse(dd.GetString(), out var dt) ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(1), + IsDeleteWholeOrder = storeElement.TryGetProperty("IsDeleteWholeOrder", out var del) && del.GetBoolean(), + IsUndoCancel = storeElement.TryGetProperty("IsUndoCancel", out var undo) && undo.GetBoolean() + }; + + if (storeElement.TryGetProperty("orders", out var ordersArray)) + { + foreach (var orderItem in ordersArray.EnumerateArray()) + { + var name = orderItem.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + var qty = orderItem.TryGetProperty("quantity", out var q) && q.TryGetDecimal(out var dec) ? dec : 0; + var unit = orderItem.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : ""; + var materialNumber = orderItem.TryGetProperty("materialNumber", out var mn) ? mn.GetString() ?? "" : ""; + var markForDelete = orderItem.TryGetProperty("markForDelete", out var md) && md.GetBoolean(); materialNumber = MatchMaterialNumber(name, materialNumber, unit, historyItems); storeDto.Orders.Add(new ExtractedOrderItemDto { + Unit = unit, Name = name, Quantity = (int)qty, - MaterialNumber = materialNumber, - Unit = unit + MarkForDelete = markForDelete, + MaterialNumber = materialNumber }); - } + } } - results.Add(storeDto); + results.Add(storeDto); } return results; } - catch (Exception ex) - { + catch (Exception ex) + { Log.Warning("解析GPT返回JSON失败: {Message}", ex.Message); return new List(); - } + } } - - private async Task> - GetCustomerHistoryItemsBySoldToIdAsync(List soldToIds, CancellationToken cancellationToken) + + private async Task> GetCustomerHistoryItemsBySoldToIdAsync(List soldToIds, CancellationToken cancellationToken) { - List<(string Material, string MaterialDesc, DateTime? InvoiceDate)> historyItems = - new List<(string, string, DateTime?)>(); + List<(string Material, string MaterialDesc, DateTime? InvoiceDate)> historyItems = new List<(string, string, DateTime?)>(); - var askInfoResponse = await _salesClient - .GetAskInfoDetailListByCustomerAsync( - new GetAskInfoDetailListByCustomerRequestDto { CustomerNumbers = soldToIds }, cancellationToken) - .ConfigureAwait(false); - var orderHistoryResponse = await _salesClient - .GetOrderHistoryByCustomerAsync( - new GetOrderHistoryByCustomerRequestDto { CustomerNumber = soldToIds.FirstOrDefault() }, - cancellationToken).ConfigureAwait(false); + var askInfoResponse = await _salesClient.GetAskInfoDetailListByCustomerAsync(new GetAskInfoDetailListByCustomerRequestDto { CustomerNumbers = soldToIds }, cancellationToken).ConfigureAwait(false); + var orderHistoryResponse = await _salesClient.GetOrderHistoryByCustomerAsync(new GetOrderHistoryByCustomerRequestDto { CustomerNumber = soldToIds.FirstOrDefault() }, cancellationToken).ConfigureAwait(false); if (askInfoResponse?.Data != null && askInfoResponse.Data.Any()) - historyItems.AddRange(askInfoResponse.Data.Where(x => !string.IsNullOrWhiteSpace(x.Material)) - .Select(x => (x.Material, x.MaterialDesc, (DateTime?)null))); + historyItems.AddRange(askInfoResponse.Data.Where(x => !string.IsNullOrWhiteSpace(x.Material)).Select(x => (x.Material, x.MaterialDesc, (DateTime?)null))); if (orderHistoryResponse?.Data != null && orderHistoryResponse.Data.Any()) - historyItems.AddRange( - orderHistoryResponse?.Data.Where(x => !string.IsNullOrWhiteSpace(x.MaterialNumber)) - .Select(x => (x.MaterialNumber, x.MaterialDescription, x.LastInvoiceDate)) ?? - new List<(string, string, DateTime?)>()); + historyItems.AddRange(orderHistoryResponse?.Data.Where(x => !string.IsNullOrWhiteSpace(x.MaterialNumber)).Select(x => (x.MaterialNumber, x.MaterialDescription, x.LastInvoiceDate)) ?? new List<(string, string, DateTime?)>()); return historyItems; } - private string MatchMaterialNumber(string itemName, string baseNumber, string unit, - List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems) + private string MatchMaterialNumber(string itemName, string baseNumber, string unit, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems) { - var candidates = historyItems - .Where(x => x.MaterialDesc != null && x.MaterialDesc.Contains(itemName, StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Material).ToList(); + var candidates = historyItems.Where(x => x.MaterialDesc != null && x.MaterialDesc.Contains(itemName, StringComparison.OrdinalIgnoreCase)).Select(x => x.Material).ToList(); Log.Information("Candidate material code list: {@Candidates}", candidates); if (!candidates.Any()) return string.IsNullOrEmpty(baseNumber) ? "" : baseNumber; - ; if (candidates.Count == 1) return candidates.First(); - if (!string.IsNullOrWhiteSpace(unit)) + var isCase = !string.IsNullOrWhiteSpace(unit) && (unit.Contains("case", StringComparison.OrdinalIgnoreCase) || unit.Contains("箱")); + if (isCase) { - var u = unit.ToLower(); - if (u.Contains("case") || u.Contains("箱")) - { - var csItem = candidates.FirstOrDefault(x => x.EndsWith("CS", StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrEmpty(csItem)) return csItem; - } - else - { - var pcItem = candidates.FirstOrDefault(x => x.EndsWith("PC", StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrEmpty(pcItem)) return pcItem; - } + var noPcList = candidates.Where(x => !x.Contains("PC", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (noPcList.Any()) + return noPcList.First(); + + return candidates.First(); } + + var pcList = candidates.Where(x => x.Contains("PC", StringComparison.OrdinalIgnoreCase)).ToList(); - var pureNumber = candidates.FirstOrDefault(x => Regex.IsMatch(x, @"^\d+$")); - return pureNumber ?? candidates.First(); + if (pcList.Any()) + return pcList.First(); + + return candidates.First(); } - private async Task ResolveSoldToIdAsync(ExtractedOrderDto storeOrder, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, List soldToIds, CancellationToken cancellationToken) - { - if (soldToIds.Count == 1) return aiSpeechAssistant.Name; + private async Task ResolveSoldToIdAsync(ExtractedOrderDto storeOrder, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, List soldToIds, CancellationToken cancellationToken) + { + if (soldToIds.Count == 1) + return soldToIds[0]; - if (!string.IsNullOrEmpty(storeOrder.StoreName)) - { - var requestDto = new GetCustomerNumbersByNameRequestDto { CustomerName = storeOrder.StoreName }; - var response = await _salesClient.GetCustomerNumbersByNameAsync(requestDto, cancellationToken).ConfigureAwait(false); - - var matchedCustomers = response?.Data?.Where(x => soldToIds.Contains(x.CustomerNumber.TrimStart('0'))).Select(x => x.CustomerNumber).ToList(); - - if (matchedCustomers?.Count == 1) return matchedCustomers.First(); + if (!string.IsNullOrEmpty(storeOrder.StoreName)) + { + var requestDto = new GetCustomerNumbersByNameRequestDto { CustomerName = storeOrder.StoreName }; + var customerNumber = await _salesClient.GetCustomerNumbersByNameAsync(requestDto, cancellationToken).ConfigureAwait(false); + return customerNumber?.Data?.FirstOrDefault()?.CustomerNumber ?? string.Empty; + } - if (matchedCustomers?.Count > 1 && - int.TryParse(storeOrder.StoreNumber, out var storeIndex) && - storeIndex > 0 && storeIndex <= matchedCustomers.Count) - { - return matchedCustomers[storeIndex - 1]; - } + if (!string.IsNullOrEmpty(storeOrder.StoreNumber) && soldToIds.Any() && int.TryParse(storeOrder.StoreNumber, out var storeIndex) && storeIndex > 0 && storeIndex <= soldToIds.Count) + { + return soldToIds[storeIndex - 1]; } - if (!string.IsNullOrEmpty(storeOrder.StoreNumber) && int.TryParse(storeOrder.StoreNumber, out var index) && index > 0 && index <= soldToIds.Count) - return soldToIds[index - 1]; - - return string.Empty; + if (soldToIds.Count > 1) return string.Empty; + + return aiSpeechAssistant.Name; } - private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder, string soldToId, - Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, TimeZoneInfo pacificZone, DateTime pacificNow) - { - var pacificDeliveryDate = storeOrder.DeliveryDate != default - ? TimeZoneInfo.ConvertTimeFromUtc(storeOrder.DeliveryDate, pacificZone) - : pacificNow.AddDays(1); + private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder, string soldToId, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, TimeZoneInfo pacificZone, DateTime pacificNow, bool useCanceledOrder) + { + var pacificDeliveryDate = storeOrder.DeliveryDate != default ? TimeZoneInfo.ConvertTimeFromUtc(storeOrder.DeliveryDate, pacificZone) : pacificNow.AddDays(1); var assistantNameWithComma = aiSpeechAssistant.Name?.Replace('/', ',') ?? string.Empty; return new GenerateAiOrdersRequestDto { AiModel = "Smartalk", + UseCanceledOrder = useCanceledOrder, AiOrderInfoDto = new AiOrderInfoDto { SoldToId = soldToId, + AiAssistantId = aiSpeechAssistant.Id, SoldToIds = string.IsNullOrEmpty(soldToId) ? assistantNameWithComma : soldToId, DocumentDate = pacificNow.Date, DeliveryDate = pacificDeliveryDate.Date, @@ -676,7 +670,8 @@ private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder MaterialNumber = i.MaterialNumber, AiMaterialDesc = i.Name, MaterialQuantity = i.Quantity, - AiUnit = i.Unit + AiUnit = i.Unit, + MarkForDelete = i.MarkForDelete }).ToList() } }; @@ -812,4 +807,47 @@ public async Task IdentifyDialogueScenariosAsync(stri return result; } + + private async Task CreateDeleteOrderTaskAsync(PhoneOrderRecord record, ExtractedOrderDto storeOrder, string soldToId, List soldToIds, TimeZoneInfo pacificZone, DateTime pacificNow, CancellationToken cancellationToken) + { + var pacificDeliveryDate = storeOrder.DeliveryDate != default ? TimeZoneInfo.ConvertTimeFromUtc(storeOrder.DeliveryDate, pacificZone) : pacificNow.AddDays(1); + var req = new DeleteAiOrderRequestDto + { + CustomerNumber = soldToId, + SoldToIds = string.Join(",", soldToIds), + DeliveryDate = pacificDeliveryDate.Date, + AiAssistantId = record.AssistantId ?? 0 + }; + + var task = new PhoneOrderPushTask + { + RecordId = record.Id, + ParentRecordId = record.ParentRecordId, + AssistantId = record.AssistantId ?? 0, + TaskType = PhoneOrderPushTaskType.DeleteOrder, + BusinessKey = $"DELETE_{storeOrder.StoreName}_{storeOrder.DeliveryDate:yyyyMMdd}", + RequestJson = JsonSerializer.Serialize(req), + Status = PhoneOrderPushTaskStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + await _salesDataProvider.AddPhoneOrderPushTaskAsync(task, true, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateGenerateOrderTaskAsync(PhoneOrderRecord record, ExtractedOrderDto storeOrder, GenerateAiOrdersRequestDto request, CancellationToken cancellationToken) + { + var task = new PhoneOrderPushTask + { + RecordId = record.Id, + ParentRecordId = record.ParentRecordId, + AssistantId = record.AssistantId ?? 0, + TaskType = PhoneOrderPushTaskType.GenerateOrder, + BusinessKey = $"{storeOrder.StoreName}_{storeOrder.DeliveryDate:yyyyMMdd}", + RequestJson = JsonSerializer.Serialize(request), + Status = PhoneOrderPushTaskStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + await _salesDataProvider.AddPhoneOrderPushTaskAsync(task, true, cancellationToken).ConfigureAwait(false); + } } diff --git a/src/SmartTalk.Core/Services/Sale/SalesDataProvider.cs b/src/SmartTalk.Core/Services/Sale/SalesDataProvider.cs index 281f7f3c3..2d9fea9b5 100644 --- a/src/SmartTalk.Core/Services/Sale/SalesDataProvider.cs +++ b/src/SmartTalk.Core/Services/Sale/SalesDataProvider.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; using SmartTalk.Core.Data; +using SmartTalk.Core.Domain.PhoneOrder; using SmartTalk.Core.Domain.Sales; using SmartTalk.Core.Ioc; +using SmartTalk.Messages.Enums.PhoneOrder; using SmartTalk.Messages.Enums.Sales; namespace SmartTalk.Core.Services.Sale; @@ -19,6 +21,20 @@ public interface ISalesDataProvider : IScopedDependency Task UpsertCustomerInfoCacheAsync(string phoneNumber, string itemsString, bool forceSave, CancellationToken cancellationToken); Task GetCustomerInfoCacheByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken); + + Task AddPhoneOrderPushTaskAsync(PhoneOrderPushTask task, bool forceSave = true, CancellationToken cancellationToken = default); + + Task MarkSendingAsync(int taskId, bool forceSave, CancellationToken cancellationToken = default); + + Task MarkSentAsync(int taskId, bool forceSave, CancellationToken cancellationToken = default); + + Task MarkFailedAsync(int taskId, bool forceSave, CancellationToken cancellationToken = default); + + Task IsParentCompletedAsync(int? parentRecordId, CancellationToken cancellationToken); + + Task HasPendingTasksByRecordIdAsync(int recordId, CancellationToken cancellationToken); + + Task GetRecordPushTaskByRecordIdAsync(int recordId, CancellationToken cancellationToken); } public class SalesDataProvider : ISalesDataProvider @@ -109,4 +125,67 @@ public async Task GetCustomerInfoCacheB { return await _repository.Query().Where(x => x.Filter == phoneNumber).FirstOrDefaultAsync(cancellationToken); } + + public async Task AddPhoneOrderPushTaskAsync(PhoneOrderPushTask task, bool forceSave = true, CancellationToken cancellationToken = default) + { + await _repository.InsertAsync(task, cancellationToken).ConfigureAwait(false); + + if (forceSave) + await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task MarkSendingAsync(int taskId, bool forceSave = true, CancellationToken cancellationToken = default) + { + var task = await _repository.Query().Where(t => t.Id == taskId).FirstOrDefaultAsync(cancellationToken); + + if (task == null) return; + + task.Status = PhoneOrderPushTaskStatus.Sending; + + await _repository.UpdateAsync(task, cancellationToken).ConfigureAwait(false); + if (forceSave) await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task MarkSentAsync(int taskId, bool forceSave = true, CancellationToken cancellationToken = default) + { + var task = await _repository.Query().Where(t => t.Id == taskId).FirstOrDefaultAsync(cancellationToken); + + if (task == null) return; + + task.Status = PhoneOrderPushTaskStatus.Sent; + + await _repository.UpdateAsync(task, cancellationToken).ConfigureAwait(false); + if (forceSave) await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task MarkFailedAsync(int taskId, bool forceSave, CancellationToken cancellationToken = default) + { + var task = await _repository.Query().Where(t => t.Id == taskId).FirstOrDefaultAsync(cancellationToken); + + if (task == null) return; + + task.Status = PhoneOrderPushTaskStatus.Failed; + + await _repository.UpdateAsync(task, cancellationToken).ConfigureAwait(false); + if (forceSave) await _unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task IsParentCompletedAsync(int? parentRecordId, CancellationToken cancellationToken) + { + if (!parentRecordId.HasValue) return true; + + return await _repository.Query().Where(r => r.Id == parentRecordId.Value).Select(r => r.IsCompleted).FirstOrDefaultAsync(cancellationToken); + } + + + public async Task HasPendingTasksByRecordIdAsync(int recordId, CancellationToken cancellationToken) + { + return await _repository.Query().AnyAsync(t => t.RecordId == recordId && t.Status != PhoneOrderPushTaskStatus.Sent, cancellationToken).ConfigureAwait(false); + } + + public async Task GetRecordPushTaskByRecordIdAsync(int recordId, CancellationToken cancellationToken) + { + return await _repository.Query().Where(t => t.RecordId == recordId && t.Status == PhoneOrderPushTaskStatus.Pending) + .OrderBy(t => t.CreatedAt).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs b/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs new file mode 100644 index 000000000..5c497fe50 --- /dev/null +++ b/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Serilog; +using SmartTalk.Core.Domain.Sales; +using SmartTalk.Core.Ioc; +using SmartTalk.Core.Services.Http.Clients; +using SmartTalk.Core.Services.Jobs; +using SmartTalk.Core.Services.PhoneOrder; +using SmartTalk.Messages.Dto.Sales; +using SmartTalk.Messages.Enums.Sales; + +namespace SmartTalk.Core.Services.Sale; + +public interface ISalesPhoneOrderPushService : IScopedDependency +{ + Task ExecutePhoneOrderPushTasksAsync(int recordId, CancellationToken cancellationToken); +} + +public class SalesPhoneOrderPushService : ISalesPhoneOrderPushService +{ + private readonly ISalesClient _salesClient; + private readonly ISalesDataProvider _salesDataProvider; + private readonly IPhoneOrderDataProvider _phoneOrderDataProvider; + private readonly ISmartTalkBackgroundJobClient _backgroundJobClient; + + public SalesPhoneOrderPushService(ISalesDataProvider salesDataProvider, ISalesClient salesClient, IPhoneOrderDataProvider phoneOrderDataProvider, ISmartTalkBackgroundJobClient backgroundJobClient) + { + _salesClient = salesClient; + _salesDataProvider = salesDataProvider; + _backgroundJobClient = backgroundJobClient; + _phoneOrderDataProvider = phoneOrderDataProvider; + } + + public async Task ExecutePhoneOrderPushTasksAsync(int recordId, CancellationToken cancellationToken) + { + var task = await _salesDataProvider.GetRecordPushTaskByRecordIdAsync(recordId, cancellationToken).ConfigureAwait(false); + + if (task == null) return; + + var parentCompleted = await _salesDataProvider.IsParentCompletedAsync(task.ParentRecordId, cancellationToken).ConfigureAwait(false); + + if (!parentCompleted) return; + + await ExecuteSingleTaskAsync(task, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSingleTaskAsync(PhoneOrderPushTask task, CancellationToken cancellationToken) + { + try + { + await _salesDataProvider.MarkSendingAsync(task.Id, true, cancellationToken).ConfigureAwait(false); + + switch (task.TaskType) + { + case PhoneOrderPushTaskType.GenerateOrder: + await ExecuteGenerateAsync(task, cancellationToken).ConfigureAwait(false); + break; + + case PhoneOrderPushTaskType.DeleteOrder: + await ExecuteDeleteAsync(task, cancellationToken).ConfigureAwait(false); + break; + } + + await _salesDataProvider.MarkSentAsync(task.Id, true, cancellationToken).ConfigureAwait(false); + + await TryCompleteRecordAsync(task.RecordId, cancellationToken).ConfigureAwait(false); + + var hasPendingTasks = await _salesDataProvider.HasPendingTasksByRecordIdAsync(task.RecordId, cancellationToken); + + if (hasPendingTasks) + { + Log.Information("Enqueuing next push task for RecordId={RecordId}", task.RecordId); + _backgroundJobClient.Enqueue( + s => s.ExecutePhoneOrderPushTasksAsync(task.RecordId, CancellationToken.None)); + } + else + { + Log.Information("No pending tasks left, record flow ends. RecordId={RecordId}", task.RecordId); + } + + } + catch (Exception ex) + { + await _salesDataProvider.MarkFailedAsync(task.Id, true, cancellationToken).ConfigureAwait(false); + + Log.Error(ex, "PhoneOrderPushTask failed. TaskId={TaskId}", task.Id); + } + } + + private async Task TryCompleteRecordAsync(int recordId, CancellationToken cancellationToken) + { + var hasPendingTasks = await _salesDataProvider.HasPendingTasksByRecordIdAsync(recordId, cancellationToken).ConfigureAwait(false); + + if (!hasPendingTasks) + { + await _phoneOrderDataProvider.MarkRecordCompletedAsync(recordId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ExecuteGenerateAsync(PhoneOrderPushTask task, CancellationToken cancellationToken) + { + var req = JsonSerializer.Deserialize(task.RequestJson); + + var resp = await _salesClient.GenerateAiOrdersAsync(req, cancellationToken).ConfigureAwait(false); + + if (resp?.Data == null || resp.Data.OrderId == Guid.Empty) + throw new Exception("GenerateAiOrdersAsync failed"); + + await _phoneOrderDataProvider.UpdateOrderIdAsync(task.RecordId, resp.Data.OrderId, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDeleteAsync(PhoneOrderPushTask task, CancellationToken cancellationToken) + { + var req = JsonSerializer.Deserialize(task.RequestJson); + + await _salesClient.DeleteAiOrderAsync(req, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/SmartTalk.Messages/Dto/Sales/DeleteAiOrderRequestDto.cs b/src/SmartTalk.Messages/Dto/Sales/DeleteAiOrderRequestDto.cs new file mode 100644 index 000000000..815700910 --- /dev/null +++ b/src/SmartTalk.Messages/Dto/Sales/DeleteAiOrderRequestDto.cs @@ -0,0 +1,21 @@ +namespace SmartTalk.Messages.Dto.Sales; + +public class DeleteAiOrderRequestDto +{ + public string CustomerNumber { get; set; } = string.Empty; + + public string SoldToIds { get; set; } = string.Empty; + + public DateTime? DeliveryDate { get; set; } + + public int AiAssistantId { get; set; } +} + +public class DeleteAiOrderResponseDto +{ + public int Code { get; set; } + + public string Message { get; set; } + + public Guid Data { get; set; } +} \ No newline at end of file diff --git a/src/SmartTalk.Messages/Dto/Sales/ExtractedOrderItemDto.cs b/src/SmartTalk.Messages/Dto/Sales/ExtractedOrderItemDto.cs index eea6d1ada..d197b3d5e 100644 --- a/src/SmartTalk.Messages/Dto/Sales/ExtractedOrderItemDto.cs +++ b/src/SmartTalk.Messages/Dto/Sales/ExtractedOrderItemDto.cs @@ -7,6 +7,10 @@ public class ExtractedOrderDto public string StoreNumber { get; set; } = string.Empty; public DateTime DeliveryDate { get; set; } + + public bool IsDeleteWholeOrder { get; set; } + + public bool IsUndoCancel { get; set; } public List Orders { get; set; } = new(); } @@ -20,4 +24,6 @@ public class ExtractedOrderItemDto public string MaterialNumber { get; set; } public string Unit { get; set; } + + public bool MarkForDelete { get; set; } } \ No newline at end of file diff --git a/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs b/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs index 330f7863a..1955253dc 100644 --- a/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs +++ b/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs @@ -5,12 +5,16 @@ public class GenerateAiOrdersRequestDto public string AiModel { get; set; } public AiOrderInfoDto AiOrderInfoDto { get; set; } + + public bool UseCanceledOrder { get; set; } } public class AiOrderInfoDto { public string SoldToId { get; set; } + public int AiAssistantId { get; set; } + public DateTime DocumentDate { get; set; } public DateTime DeliveryDate { get; set; } @@ -33,4 +37,6 @@ public class AiOrderItemDto public int MaterialQuantity { get; set; } = 1; public string AiUnit { get; set; } + + public bool MarkForDelete { get; set; } } \ No newline at end of file diff --git a/src/SmartTalk.Messages/Dto/Sales/GetAskInfoDetailListByCustomerRequestDto.cs b/src/SmartTalk.Messages/Dto/Sales/GetAskInfoDetailListByCustomerRequestDto.cs index c19b0ed9c..9dcc2fff8 100644 --- a/src/SmartTalk.Messages/Dto/Sales/GetAskInfoDetailListByCustomerRequestDto.cs +++ b/src/SmartTalk.Messages/Dto/Sales/GetAskInfoDetailListByCustomerRequestDto.cs @@ -57,4 +57,6 @@ public class VwAskDetail public string LevelCode { get; set; } public string LevelText { get; set; } + + public double Atr { get; set; } } \ No newline at end of file diff --git a/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskStatus.cs b/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskStatus.cs new file mode 100644 index 000000000..f1fea1e1c --- /dev/null +++ b/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskStatus.cs @@ -0,0 +1,9 @@ +namespace SmartTalk.Messages.Enums.Sales; + +public enum PhoneOrderPushTaskStatus +{ + Pending = 10, + Sending = 20, + Sent = 30, + Failed = 40 +} \ No newline at end of file diff --git a/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskType.cs b/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskType.cs new file mode 100644 index 000000000..567f0b384 --- /dev/null +++ b/src/SmartTalk.Messages/Enums/Sales/PhoneOrderPushTaskType.cs @@ -0,0 +1,7 @@ +namespace SmartTalk.Messages.Enums.Sales; + +public enum PhoneOrderPushTaskType +{ + GenerateOrder = 10, + DeleteOrder = 20 +} \ No newline at end of file diff --git a/src/SmartTalk.Messages/Requests/PhoneOrder/GetPhoneOrderRecordsRequest.cs b/src/SmartTalk.Messages/Requests/PhoneOrder/GetPhoneOrderRecordsRequest.cs index 505b2578c..0f8976f15 100644 --- a/src/SmartTalk.Messages/Requests/PhoneOrder/GetPhoneOrderRecordsRequest.cs +++ b/src/SmartTalk.Messages/Requests/PhoneOrder/GetPhoneOrderRecordsRequest.cs @@ -21,6 +21,8 @@ public class GetPhoneOrderRecordsRequest : IRequest public DateTimeOffset? Date { get; set; } + public List OrderIds { get; set; } + public string OrderId { get; set; } public int? AssistantId { get; set; } @@ -30,4 +32,4 @@ public class GetPhoneOrderRecordsRequest : IRequest public class GetPhoneOrderRecordsResponse : SmartTalkResponse> { -} +} \ No newline at end of file