From 4d5a664b571767253ea011b5148f0371fc66ba3f Mon Sep 17 00:00:00 2001 From: 157 Date: Fri, 22 May 2026 16:36:31 +0800 Subject: [PATCH 1/4] Ai order 1.3 --- .../Controllers/PhoneOrderController.cs | 9 + .../Constants/PhoneOrderSourceProviders.cs | 6 + ...aixvolink_fields_to_phone_order_record.sql | 2 + .../Domain/PhoneOrder/PhoneOrderRecord.cs | 5 +- ...AixvolinkPhoneOrderRecordCommandHandler.cs | 25 +++ .../Services/Http/Clients/CrmClient.cs | 62 +++--- .../Services/Http/Clients/SalesClient.cs | 24 ++- .../PhoneOrderProcessJobService.Record.cs | 102 +++++++--- .../PhoneOrder/PhoneOrderProcessJobService.cs | 9 +- .../PhoneOrder/PhoneOrderService.Record.cs | 49 +++++ .../Sale/SalesCustomerMatchService.cs | 183 ++++++++++++++++++ .../Sale/SalesPhoneOrderPushService.cs | 3 +- .../PhoneOrder/AixvolinkPhoneOrderSetting.cs | 16 ++ ...ReceiveAixvolinkPhoneOrderRecordCommand.cs | 21 ++ .../Dto/Sales/GenerateAiOrdersRequestDto.cs | 4 +- .../GetSalesGroupByPhoneNumberResponseDto.cs | 28 +++ .../Sale/SalesCustomerMatchServiceTests.cs | 129 ++++++++++++ .../Sale/SalesPhoneOrderPushServiceTests.cs | 45 +++++ 18 files changed, 664 insertions(+), 58 deletions(-) create mode 100644 src/SmartTalk.Core/Constants/PhoneOrderSourceProviders.cs create mode 100644 src/SmartTalk.Core/DbUpFile/Scripts_2026/Script0009_add_aixvolink_fields_to_phone_order_record.sql create mode 100644 src/SmartTalk.Core/Handlers/CommandHandlers/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommandHandler.cs create mode 100644 src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs create mode 100644 src/SmartTalk.Core/Settings/PhoneOrder/AixvolinkPhoneOrderSetting.cs create mode 100644 src/SmartTalk.Messages/Commands/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommand.cs create mode 100644 src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs create mode 100644 src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs create mode 100644 src/SmartTalk.UnitTests/Services/Sale/SalesPhoneOrderPushServiceTests.cs diff --git a/src/SmartTalk.Api/Controllers/PhoneOrderController.cs b/src/SmartTalk.Api/Controllers/PhoneOrderController.cs index bbb0b4800..071f74a51 100644 --- a/src/SmartTalk.Api/Controllers/PhoneOrderController.cs +++ b/src/SmartTalk.Api/Controllers/PhoneOrderController.cs @@ -106,6 +106,15 @@ await _mediator.SendAsync(new ReceivePhoneOrderRecordCommand { return Ok(); } + [HttpPost("aixvolink/call-ended")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ReceiveAixvolinkCallEndedAsync([FromBody] ReceiveAixvolinkPhoneOrderRecordCommand command) + { + await _mediator.SendAsync(command).ConfigureAwait(false); + + return Ok(); + } + [Route("manual/order"), HttpPost] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AddOrUpdateManualOrderResponse))] public async Task AddOrUpdateManualOrderAsync([FromBody] AddOrUpdateManualOrderCommand command) diff --git a/src/SmartTalk.Core/Constants/PhoneOrderSourceProviders.cs b/src/SmartTalk.Core/Constants/PhoneOrderSourceProviders.cs new file mode 100644 index 000000000..cf51294cb --- /dev/null +++ b/src/SmartTalk.Core/Constants/PhoneOrderSourceProviders.cs @@ -0,0 +1,6 @@ +namespace SmartTalk.Core.Constants; + +public static class PhoneOrderSourceProviders +{ + public const string Aixvolink = "AIXVOLINK"; +} diff --git a/src/SmartTalk.Core/DbUpFile/Scripts_2026/Script0009_add_aixvolink_fields_to_phone_order_record.sql b/src/SmartTalk.Core/DbUpFile/Scripts_2026/Script0009_add_aixvolink_fields_to_phone_order_record.sql new file mode 100644 index 000000000..aed6de939 --- /dev/null +++ b/src/SmartTalk.Core/DbUpFile/Scripts_2026/Script0009_add_aixvolink_fields_to_phone_order_record.sql @@ -0,0 +1,2 @@ +ALTER TABLE `phone_order_record` + ADD COLUMN `source_provider` VARCHAR(36) NULL AFTER `is_completed`; diff --git a/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs b/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs index 0b7648ea7..fecd6f0c4 100644 --- a/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs +++ b/src/SmartTalk.Core/Domain/PhoneOrder/PhoneOrderRecord.cs @@ -116,4 +116,7 @@ public class PhoneOrderRecord : IEntity [Column("is_completed")] public bool IsCompleted { get; set; } = false; -} \ No newline at end of file + + [Column("source_provider"), StringLength(36)] + public string SourceProvider { get; set; } +} diff --git a/src/SmartTalk.Core/Handlers/CommandHandlers/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommandHandler.cs b/src/SmartTalk.Core/Handlers/CommandHandlers/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommandHandler.cs new file mode 100644 index 000000000..d7e54ad5b --- /dev/null +++ b/src/SmartTalk.Core/Handlers/CommandHandlers/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommandHandler.cs @@ -0,0 +1,25 @@ +using Mediator.Net.Context; +using Mediator.Net.Contracts; +using SmartTalk.Core.Constants; +using SmartTalk.Core.Services.Jobs; +using SmartTalk.Core.Services.PhoneOrder; +using SmartTalk.Messages.Commands.PhoneOrder; + +namespace SmartTalk.Core.Handlers.CommandHandlers.PhoneOrder; + +public class ReceiveAixvolinkPhoneOrderRecordCommandHandler : ICommandHandler +{ + private readonly ISmartTalkBackgroundJobClient _backgroundJobClient; + + public ReceiveAixvolinkPhoneOrderRecordCommandHandler(ISmartTalkBackgroundJobClient backgroundJobClient) + { + _backgroundJobClient = backgroundJobClient; + } + + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) + { + _backgroundJobClient.Enqueue(x => x.ReceiveAixvolinkPhoneOrderRecordAsync(context.Message, cancellationToken), HangfireConstants.InternalHostingPhoneOrder); + + return Task.CompletedTask; + } +} diff --git a/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs b/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs index 64993065b..ac2b9cd44 100644 --- a/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs +++ b/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs @@ -1,5 +1,6 @@ using System.Text; using Newtonsoft.Json; +using Serilog; using SmartTalk.Core.Ioc; using SmartTalk.Core.Settings.Crm; using SmartTalk.Messages.Dto.AutoTest; @@ -17,6 +18,8 @@ public interface ICrmClient : IScopedDependency Task> GetCustomersByPhoneNumberAsync(GetCustmoersByPhoneNumberRequestDto numberRequest, string token = null, CancellationToken cancellationToken = default); + Task> GetCustomersByRestaurantNameAsync(string restaurantName, string token = null, CancellationToken cancellationToken = default); + Task> GetCustomerContactsAsync(string customerId, string token = null, CancellationToken cancellationToken = default); Task> GetDeliveryInfoByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken = default); @@ -24,8 +27,8 @@ public interface ICrmClient : IScopedDependency public class CrmClient : ICrmClient { + private const string CustomerByRestaurantNamePath = "/api/customer/get-customers-by-restaurant-name?restaurant_name={0}"; private readonly CrmSetting _crmSetting; - private readonly Dictionary _headers; private readonly ISmartTalkHttpClientFactory _httpClient; public CrmClient(ISmartTalkHttpClientFactory httpClient, CrmSetting crmSetting) @@ -44,9 +47,9 @@ public async Task GetCrmTokenAsync(CancellationToken cancellationToken) { "client_id", _crmSetting.ClientId }, { "client_secret", _crmSetting.ClientSecret } }; - + var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); - + var headers = new Dictionary { { "Accept", "application/json" } @@ -57,38 +60,48 @@ public async Task GetCrmTokenAsync(CancellationToken cancellationToken) return response?.AccessToken; } - public async Task> GetCustomerContactsAsync(string customerId, - CancellationToken cancellationToken) + public Task> GetCustomerContactsAsync(string customerId, CancellationToken cancellationToken) + { + return GetCustomerContactsAsync(customerId, token: null, cancellationToken); + } + + public async Task> GetCustomersByPhoneNumberAsync(GetCustmoersByPhoneNumberRequestDto numberRequest, string token = null, CancellationToken cancellationToken = default) { - var token = await GetCrmTokenAsync(cancellationToken).ConfigureAwait(false); + token ??= await GetCrmTokenAsync(cancellationToken).ConfigureAwait(false); var headers = new Dictionary { { "Accept", "application/json" }, - { "Authorization", $"Bearer {token}" } + { "Authorization", $"Bearer {token}"} }; - return await _httpClient - .GetAsync>($"{_crmSetting.BaseUrl}/api/customer/{customerId}/contacts", - headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false); + var url = $"{_crmSetting.BaseUrl}/api/customer/get-customers-by-phone-number?phone_number={numberRequest.PhoneNumber}"; + + var result = await _httpClient.GetAsync>(url, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; + + Log.Information("Found {Count} customers for phone {PhoneNumber}", result.Count, numberRequest.PhoneNumber); + return result; } - - - public async Task> GetCustomersByPhoneNumberAsync(GetCustmoersByPhoneNumberRequestDto numberRequest, string token = null, CancellationToken cancellationToken = default) + + public async Task> GetCustomersByRestaurantNameAsync(string restaurantName, string token = null, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(restaurantName)) + return []; + token ??= await GetCrmTokenAsync(cancellationToken).ConfigureAwait(false); - + var headers = new Dictionary { { "Accept", "application/json" }, - { "Authorization", $"Bearer {token}"} + { "Authorization", $"Bearer {token}" } }; - - var url = $"{_crmSetting.BaseUrl}/api/customer/get-customers-by-phone-number?phone_number={numberRequest.PhoneNumber}"; - return await _httpClient - .GetAsync>(url, headers: headers, cancellationToken: cancellationToken) - .ConfigureAwait(false); + var url = $"{_crmSetting.BaseUrl}{string.Format(CustomerByRestaurantNamePath, Uri.EscapeDataString(restaurantName))}"; + + var result = await _httpClient.GetAsync>(url, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; + + Log.Information("Found {Count} customers for restaurant {RestaurantName}", result.Count, restaurantName); + return result; } public async Task> GetCallRecordsAsync(DateTime startTimeUtc, DateTime endTimeUtc, CancellationToken cancellationToken) @@ -116,7 +129,12 @@ public async Task> GetCustomerContactsAsync(string customerI { "Authorization", $"Bearer {token}" } }; - return await _httpClient.GetAsync>($"{_crmSetting.BaseUrl}/api/customer/{customerId}/contacts", headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false); + Log.Information("Fetching contacts for customer {CustomerId}", customerId); + + var contacts = await _httpClient.GetAsync>($"{_crmSetting.BaseUrl}/api/customer/{customerId}/contacts", headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; + Log.Information("Found {Count} contacts for customer {CustomerId}", contacts.Count, customerId); + + return contacts; } public async Task> GetDeliveryInfoByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken = default) @@ -134,4 +152,4 @@ public async Task> GetDeliveryInfo return await _httpClient.GetAsync>(url, cancellationToken: cancellationToken, headers: headers).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs index b3b22274f..092f2e898 100644 --- a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs +++ b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs @@ -19,6 +19,8 @@ public interface ISalesClient : IScopedDependency Task GetCustomerNumbersByNameAsync(GetCustomerNumbersByNameRequestDto request, CancellationToken cancellationToken); Task GetCustomerLevel5HabitAsync(GetCustomerLevel5HabitRequstDto request, CancellationToken cancellationToken); + + Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken); Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken); @@ -50,13 +52,11 @@ public async Task GetAskInfoDetailLis { if (request.CustomerNumbers == null || request.CustomerNumbers.Count == 0) throw new ArgumentException("CustomerNumbers cannot be null or empty."); - + var queryString = new StringBuilder("?"); foreach (var customerNumber in request.CustomerNumbers) - { queryString.Append("CustomerNumbers=").Append(Uri.EscapeDataString(customerNumber)).Append('&'); - } var url = $"{_salesSetting.BaseUrl}/api/SalesOrder/GetAskInfoDetailListByCustomer" + queryString.ToString().TrimEnd('&'); @@ -109,6 +109,21 @@ public async Task GetCustomerLevel5HabitAsync return await _httpClientFactory.PostAsJsonAsync($"{_salesCustomerHabitSetting.BaseUrl}/api/CustomerInfo/QueryHistoryCustomerLevel5Habit", request, headers: header, cancellationToken: cancellationToken).ConfigureAwait(false); } + public async Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + return string.Empty; + + var url = $"{_salesSetting.BaseUrl}/api/external/table/query?tableId=d45f6888-4855-4114-8a9c-1ccc641e43ac" + + $"&apiKey={Uri.EscapeDataString(_salesSetting.ApiKey)}" + + "&field=phoneNumber&op=eq" + + $"&value={Uri.EscapeDataString(phoneNumber)}"; + + var response = await _httpClientFactory.GetAsync(url, cancellationToken: cancellationToken).ConfigureAwait(false); + + return response?.Rows?.FirstOrDefault()?.SalesGroup?.Trim() ?? string.Empty; + } + public async Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken) { return await _httpClientFactory.PostAsJsonAsync($"{_salesSetting.BaseUrl}/api/SalesOrder/DeleteAiOrder", request, headers: _headers, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -126,4 +141,5 @@ public async Task GetAiOrderItemsByDel return await _httpClientFactory.GetAsync(url, headers: _headers, cancellationToken: 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 96b7de918..2bfcb6a31 100644 --- a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs +++ b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.Record.cs @@ -1,4 +1,3 @@ -using Twilio; using Serilog; using OpenAI; using OpenAI.Chat; @@ -8,8 +7,6 @@ using SmartTalk.Core.Domain.System; using SmartTalk.Messages.Dto.Agent; using SmartTalk.Messages.Enums.STT; -using Twilio.Rest.Api.V2010.Account; -using SmartTalk.Messages.Dto.Sales; using Microsoft.IdentityModel.Tokens; using System.Text.RegularExpressions; using Smarties.Messages.DTO.OpenAi; @@ -24,6 +21,7 @@ using SmartTalk.Messages.Dto.AiSpeechAssistant; using SmartTalk.Messages.Dto.PhoneOrder; using JsonDocument = System.Text.Json.JsonDocument; +using SmartTalk.Messages.Dto.Sales; using JsonSerializer = System.Text.Json.JsonSerializer; using System.ClientModel; using System.Text.Encodings.Web; @@ -100,22 +98,25 @@ private async Task SummarizeConversationContentAsync(PhoneOrderRecord record, by Log.Information("Get Assistant: {@Assistant} and Agent: {@Agent} by agent id {agentId}", aiSpeechAssistant, agent, record.AgentId); - var callFrom = string.Empty; - var callTo = string.Empty; - - try + var callFrom = record.PhoneNumber ?? string.Empty; + var callTo = record.IncomingCallNumber ?? string.Empty; + + if (string.IsNullOrWhiteSpace(callFrom) && !string.Equals(record.SourceProvider, PhoneOrderSourceProviders.Aixvolink, StringComparison.OrdinalIgnoreCase)) { - await RetryHelper.RetryAsync(async () => + try { - var callInfo = await _twilioService.FetchCallAsync(record.SessionId); - callFrom = callInfo?.From; - callTo = callInfo?.To; - Log.Information("Fetched incoming phone number from Twilio: {callFrom}", callFrom); - }, maxRetryCount: 3, delaySeconds: 3, cancellationToken); - } - catch (Exception e) - { - Log.Warning("Fetched incoming phone number from Twilio failed: {Message}", e.Message); + await RetryHelper.RetryAsync(async () => + { + var callInfo = await _twilioService.FetchCallAsync(record.SessionId); + callFrom = callInfo?.From; + callTo = callInfo?.To; + Log.Information("Fetched incoming phone number from Twilio: {callFrom}", callFrom); + }, maxRetryCount: 3, delaySeconds: 3, cancellationToken); + } + catch (Exception e) + { + Log.Warning("Fetched incoming phone number from Twilio failed: {Message}", e.Message); + } } var pstTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")); @@ -452,7 +453,9 @@ private async Task HandleSalesScenarioAsync(Agent agent, Domain.AISpeechAssistan if (!string.IsNullOrEmpty(aiSpeechAssistant.Name)) soldToIds = aiSpeechAssistant.Name.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList(); - var historyItems = await GetCustomerHistoryItemsBySoldToIdAsync(soldToIds, cancellationToken).ConfigureAwait(false); + var historyItems = soldToIds.Count > 0 + ? await GetCustomerHistoryItemsBySoldToIdAsync(soldToIds, cancellationToken).ConfigureAwait(false) + : []; var extractedOrders = await ExtractAndMatchOrderItemsFromReportAsync(record.TranscriptionText, historyItems, cancellationToken).ConfigureAwait(false); if (!extractedOrders.Any()) return; @@ -462,20 +465,35 @@ private async Task HandleSalesScenarioAsync(Agent agent, Domain.AISpeechAssistan foreach (var storeOrder in extractedOrders) { - var soldToId = await ResolveSoldToIdAsync(storeOrder, aiSpeechAssistant, soldToIds, cancellationToken).ConfigureAwait(false); + var customerMatch = await ResolveSalesCustomerMatchAsync(record, storeOrder, aiSpeechAssistant, soldToIds, cancellationToken).ConfigureAwait(false); + var soldToId = customerMatch.SoldToId; + var matchedSoldToIds = customerMatch.SoldToIds.Count > 0 + ? customerMatch.SoldToIds + : string.Equals(record.SourceProvider, PhoneOrderSourceProviders.Aixvolink, StringComparison.OrdinalIgnoreCase) + ? [] + : soldToIds; + + if (matchedSoldToIds.Count > 0 && storeOrder.Orders.Any(x => string.IsNullOrWhiteSpace(x.MaterialNumber))) + { + var matchedHistoryItems = await GetCustomerHistoryItemsBySoldToIdAsync(matchedSoldToIds, cancellationToken).ConfigureAwait(false); + BackfillMaterialNumbers(storeOrder, matchedHistoryItems); + } if (storeOrder.IsDeleteWholeOrder && !storeOrder.Orders.Any()) { - await CreateDeleteOrderTaskAsync(record, storeOrder, soldToId, soldToIds, pacificZone, pacificNow, cancellationToken).ConfigureAwait(false); + await CreateDeleteOrderTaskAsync(record, storeOrder, soldToId, matchedSoldToIds, pacificZone, pacificNow, cancellationToken).ConfigureAwait(false); continue; } - await RefineOrderByAiAsync(storeOrder, soldToId, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(soldToId)) + await RefineOrderByAiAsync(storeOrder, soldToId, cancellationToken).ConfigureAwait(false); - var draftOrder = CreateDraftOrder(storeOrder, soldToId, aiSpeechAssistant, pacificZone, pacificNow, storeOrder.IsUndoCancel); + var draftOrder = CreateDraftOrder(record, storeOrder, soldToId, matchedSoldToIds, customerMatch.SalesGroup, aiSpeechAssistant, pacificZone, pacificNow, storeOrder.IsUndoCancel); await CreateGenerateOrderTaskAsync(record, storeOrder, draftOrder, cancellationToken).ConfigureAwait(false); } + + await _phoneOrderDataProvider.UpdatePhoneOrderRecordsAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false); } private async Task> ExtractAndMatchOrderItemsFromReportAsync(string reportText, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems, CancellationToken cancellationToken) @@ -681,11 +699,10 @@ private async Task ResolveSoldToIdAsync(ExtractedOrderDto storeOrder, Do return aiSpeechAssistant.Name; } - private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder, string soldToId, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, TimeZoneInfo pacificZone, DateTime pacificNow, bool useCanceledOrder) + private GenerateAiOrdersRequestDto CreateDraftOrder(PhoneOrderRecord record, ExtractedOrderDto storeOrder, string soldToId, List soldToIds, string salesGroup, 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; + var soldToIdsText = soldToIds.Count > 0 ? string.Join(",", soldToIds) : string.Empty; return new GenerateAiOrdersRequestDto { @@ -695,7 +712,8 @@ private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder { SoldToId = soldToId, AiAssistantId = aiSpeechAssistant.Id, - SoldToIds = string.IsNullOrEmpty(soldToId) ? assistantNameWithComma : soldToId, + SoldToIds = string.IsNullOrEmpty(soldToId) ? soldToIdsText : soldToId, + SalesGroup = salesGroup, DocumentDate = pacificNow.Date, DeliveryDate = pacificDeliveryDate.Date, AiOrderItemDtoList = storeOrder.Orders.Select(i => new AiOrderItemDto @@ -711,6 +729,38 @@ private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder } }; } + + private async Task ResolveSalesCustomerMatchAsync( + PhoneOrderRecord record, + ExtractedOrderDto storeOrder, + Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, + List soldToIds, + CancellationToken cancellationToken) + { + if (!string.Equals(record.SourceProvider, PhoneOrderSourceProviders.Aixvolink, StringComparison.OrdinalIgnoreCase)) + { + var soldToId = await ResolveSoldToIdAsync(storeOrder, aiSpeechAssistant, soldToIds, cancellationToken).ConfigureAwait(false); + return new SalesCustomerMatchResult + { + SoldToId = soldToId, + SoldToIds = string.IsNullOrWhiteSpace(soldToId) ? soldToIds : [soldToId] + }; + } + + var matched = await _salesCustomerMatchService + .MatchCustomerAsync(record.PhoneNumber, record.IncomingCallNumber, storeOrder.StoreName, [record.PhoneNumber, record.IncomingCallNumber], cancellationToken) + .ConfigureAwait(false); + + return matched; + } + + private void BackfillMaterialNumbers(ExtractedOrderDto storeOrder, List<(string Material, string MaterialDesc, DateTime? InvoiceDate)> historyItems) + { + if (historyItems.Count == 0) return; + + foreach (var order in storeOrder.Orders.Where(x => string.IsNullOrWhiteSpace(x.MaterialNumber))) + order.MaterialNumber = MatchMaterialNumber(order.AiMaterialDesc, order.MaterialNumber, order.Unit, historyItems); + } private async Task<(bool IsHumanAnswered, bool IsCustomerFriendly)> CheckCustomerFriendlyAsync(string transcriptionText, CancellationToken cancellationToken) { diff --git a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.cs b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.cs index 951d703a3..f5d971772 100644 --- a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.cs +++ b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderProcessJobService.cs @@ -44,6 +44,7 @@ public partial class PhoneOrderProcessJobService : IPhoneOrderProcessJobService private readonly ISmartTalkBackgroundJobClient _smartTalkBackgroundJobClient; private readonly IAiSpeechAssistantDataProvider _aiSpeechAssistantDataProvider; private readonly IPhoneOrderUtilService _phoneOrderUtilService; + private readonly ISalesCustomerMatchService _salesCustomerMatchService; public PhoneOrderProcessJobService( ISalesClient salesClient, @@ -61,7 +62,10 @@ public PhoneOrderProcessJobService( ISmartTalkHttpClientFactory smartTalkHttpClientFactory, ISmartTalkBackgroundJobClient backgroundJobClient, ISmartTalkBackgroundJobClient smartTalkBackgroundJobClient, - IAiSpeechAssistantDataProvider aiSpeechAssistantDataProvider, IPosUtilService posUtilService, IPhoneOrderUtilService phoneOrderUtilService) + IAiSpeechAssistantDataProvider aiSpeechAssistantDataProvider, + IPosUtilService posUtilService, + IPhoneOrderUtilService phoneOrderUtilService, + ISalesCustomerMatchService salesCustomerMatchService) { _salesClient = salesClient; _ffmpegService = ffmpegService; @@ -73,14 +77,15 @@ public PhoneOrderProcessJobService( _phoneOrderService = phoneOrderService; _salesDataProvider = salesDataProvider; _smartTalkHttpClient = smartTalkHttpClient; + _backgroundJobClient = backgroundJobClient; _phoneOrderDataProvider = phoneOrderDataProvider; _speechMaticsDataProvider = speechMaticsDataProvider; _smartTalkHttpClientFactory = smartTalkHttpClientFactory; - _smartTalkBackgroundJobClient = backgroundJobClient; _smartTalkBackgroundJobClient = smartTalkBackgroundJobClient; _aiSpeechAssistantDataProvider = aiSpeechAssistantDataProvider; _posUtilService = posUtilService; _phoneOrderUtilService = phoneOrderUtilService; + _salesCustomerMatchService = salesCustomerMatchService; } public async Task CalculatePhoneOrderRecodingDurationAsync(SchedulingCalculatePhoneOrderRecodingDurationCommand command, CancellationToken cancellationToken) diff --git a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderService.Record.cs b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderService.Record.cs index 01ceb187f..77877499f 100644 --- a/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderService.Record.cs +++ b/src/SmartTalk.Core/Services/PhoneOrder/PhoneOrderService.Record.cs @@ -29,6 +29,7 @@ using SmartTalk.Messages.Enums.Pos; using SmartTalk.Messages.Events.PhoneOrder; using SmartTalk.Core.Extensions; +using SmartTalk.Core.Constants; using TranscriptionFileType = SmartTalk.Messages.Enums.STT.TranscriptionFileType; using TranscriptionResponseFormat = SmartTalk.Messages.Enums.STT.TranscriptionResponseFormat; @@ -42,6 +43,8 @@ public partial interface IPhoneOrderService Task ReceivePhoneOrderRecordAsync(ReceivePhoneOrderRecordCommand command, CancellationToken cancellationToken); + Task ReceiveAixvolinkPhoneOrderRecordAsync(ReceiveAixvolinkPhoneOrderRecordCommand command, CancellationToken cancellationToken); + Task ExtractPhoneOrderRecordAiMenuAsync(List phoneOrderInfo, PhoneOrderRecord record, byte[] audioContent, CancellationToken cancellationToken); Task AddOrUpdateManualOrderAsync(AddOrUpdateManualOrderCommand command, CancellationToken cancellationToken); @@ -175,6 +178,52 @@ public async Task ReceivePhoneOrderRecordAsync(ReceivePhoneOrderRecordCommand co await AddPhoneOrderRecordAsync(record, PhoneOrderRecordStatus.Diarization, cancellationToken).ConfigureAwait(false); } + public async Task ReceiveAixvolinkPhoneOrderRecordAsync(ReceiveAixvolinkPhoneOrderRecordCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.RecordingUrl)) return; + + if (command.AgentId <= 0) + { + Log.Warning("AIXVOLINK record skipped because AgentId is missing."); + return; + } + + var recordContent = await _httpClientFactory.GetAsync(command.RecordingUrl, cancellationToken).ConfigureAwait(false); + var transcription = await _speechToTextService.SpeechToTextAsync( + recordContent, + fileType: TranscriptionFileType.Wav, + responseFormat: TranscriptionResponseFormat.Text, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var detection = await _translationClient.DetectLanguageAsync(transcription, cancellationToken).ConfigureAwait(false); + var recordName = $"{Guid.NewGuid():N}.wav"; + + var record = new PhoneOrderRecord + { + SessionId = Guid.NewGuid().ToString("N"), + AgentId = command.AgentId, + AssistantId = command.AssistantId, + Language = SelectLanguageEnum(detection.Language), + CreatedDate = command.CallTime == default ? DateTimeOffset.UtcNow : command.CallTime, + Status = PhoneOrderRecordStatus.Recieved, + OrderRecordType = command.OrderRecordType, + PhoneNumber = command.CallerNumber, + IncomingCallNumber = command.CalleeNumber, + Url = command.RecordingUrl, + SourceProvider = PhoneOrderSourceProviders.Aixvolink + }; + + if (await CheckPhoneOrderRecordDurationAsync(recordContent, cancellationToken).ConfigureAwait(false)) + { + await AddPhoneOrderRecordAsync(record, PhoneOrderRecordStatus.NoContent, cancellationToken).ConfigureAwait(false); + return; + } + + record.TranscriptionJobId = await _speechMaticsService.CreateSpeechMaticsJobAsync(recordContent, recordName, detection.Language, SpeechMaticsJobScenario.Released, cancellationToken).ConfigureAwait(false); + + await AddPhoneOrderRecordAsync(record, PhoneOrderRecordStatus.Diarization, cancellationToken).ConfigureAwait(false); + } + private async Task CheckOrderExistAsync(int agentId, DateTimeOffset createdDate, CancellationToken cancellationToken) { return (await _phoneOrderDataProvider.GetPhoneOrderRecordsAsync(agentId: agentId, createdDate: createdDate, cancellationToken: cancellationToken).ConfigureAwait(false)).Any(); diff --git a/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs new file mode 100644 index 000000000..e605bfcfc --- /dev/null +++ b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs @@ -0,0 +1,183 @@ +using Serilog; +using SmartTalk.Core.Ioc; +using SmartTalk.Core.Services.Http.Clients; +using SmartTalk.Messages.Dto.Crm; + +namespace SmartTalk.Core.Services.Sale; + +public interface ISalesCustomerMatchService : IScopedDependency +{ + Task MatchCustomerAsync(string callerNumber, string calleeNumber, string storeName, IEnumerable salesPhoneNumbers, CancellationToken cancellationToken); +} + +public class SalesCustomerMatchResult +{ + public string SoldToId { get; set; } = string.Empty; + + public List SoldToIds { get; set; } = []; + + public string SalesGroup { get; set; } = string.Empty; +} + +public class SalesCustomerMatchService : ISalesCustomerMatchService +{ + private readonly ICrmClient _crmClient; + private readonly ISalesClient _salesClient; + + public SalesCustomerMatchService(ICrmClient crmClient, ISalesClient salesClient) + { + _crmClient = crmClient; + _salesClient = salesClient; + } + + public async Task MatchCustomerAsync(string callerNumber, string calleeNumber, string storeName, IEnumerable salesPhoneNumbers, CancellationToken cancellationToken) + { + var crmToken = await TryGetCrmTokenAsync(cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(crmToken)) + { + var phoneMatch = await MatchByPhonesAsync([callerNumber, calleeNumber], crmToken, cancellationToken).ConfigureAwait(false); + if (phoneMatch.SoldToIds.Count > 0) + return phoneMatch; + + var storeMatch = await MatchByStoreNameAsync(storeName, crmToken, cancellationToken).ConfigureAwait(false); + if (storeMatch.SoldToIds.Count > 0) + return storeMatch; + } + + var salesGroup = await MatchSalesGroupByPhonesAsync(salesPhoneNumbers, cancellationToken).ConfigureAwait(false); + + return new SalesCustomerMatchResult + { + SalesGroup = salesGroup + }; + } + + private async Task TryGetCrmTokenAsync(CancellationToken cancellationToken) + { + try + { + return await _crmClient.GetCrmTokenAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Warning(ex, "CRM customer matching is skipped because CRM token cannot be obtained."); + return string.Empty; + } + } + + private async Task MatchByPhonesAsync(IEnumerable phoneNumbers, string crmToken, CancellationToken cancellationToken) + { + var normalizedPhones = phoneNumbers + .Select(NormalizePhone) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var phoneNumber in normalizedPhones) + { + List customers; + + try + { + customers = await _crmClient.GetCustomersByPhoneNumberAsync( + new GetCustmoersByPhoneNumberRequestDto { PhoneNumber = phoneNumber }, + crmToken, + cancellationToken).ConfigureAwait(false) ?? []; + } + catch (Exception ex) + { + Log.Warning(ex, "MatchByPhonesAsync failed for phone {PhoneNumber}", phoneNumber); + continue; + } + + var soldToIds = customers + .Select(x => NormalizeCustomerId(x.SapId)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (soldToIds.Count == 0) continue; + + return new SalesCustomerMatchResult + { + SoldToId = soldToIds.Count == 1 ? soldToIds[0] : string.Empty, + SoldToIds = soldToIds + }; + } + + return new SalesCustomerMatchResult(); + } + + private async Task MatchByStoreNameAsync(string storeName, string crmToken, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(storeName)) + return new SalesCustomerMatchResult(); + + var soldToIds = new List(); + + try + { + var crmCustomers = await _crmClient.GetCustomersByRestaurantNameAsync(storeName, crmToken, cancellationToken).ConfigureAwait(false); + soldToIds = crmCustomers + .Select(x => NormalizeCustomerId(x.SapId)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + Log.Warning(ex, "CRM store-name matching failed for store {StoreName}", storeName); + } + + return new SalesCustomerMatchResult + { + SoldToId = soldToIds.Count == 1 ? soldToIds[0] : string.Empty, + SoldToIds = soldToIds + }; + } + + private async Task MatchSalesGroupByPhonesAsync(IEnumerable phoneNumbers, CancellationToken cancellationToken) + { + var normalizedPhones = phoneNumbers + .Select(NormalizePhone) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var phoneNumber in normalizedPhones) + { + try + { + var salesGroup = await _salesClient.GetSalesGroupByPhoneNumberAsync(phoneNumber, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(salesGroup)) + return salesGroup.Trim(); + } + catch (Exception ex) + { + Log.Warning(ex, "SalesGroup matching failed for phone {PhoneNumber}", phoneNumber); + } + } + + return string.Empty; + } + + private static string NormalizePhone(string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) return string.Empty; + + var digits = new string(phoneNumber.Where(char.IsDigit).ToArray()); + if (digits.Length == 10) return "+1" + digits; + if (digits.Length == 11 && digits.StartsWith("1", StringComparison.Ordinal)) return "+" + digits; + + return phoneNumber.Trim(); + } + + private static string NormalizeCustomerId(string customerId) + { + if (string.IsNullOrWhiteSpace(customerId)) return string.Empty; + + var normalized = customerId.Trim().TrimStart('0'); + return string.IsNullOrWhiteSpace(normalized) ? "0" : normalized; + } +} diff --git a/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs b/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs index 305bc637e..f0764c9c2 100644 --- a/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs +++ b/src/SmartTalk.Core/Services/Sale/SalesPhoneOrderPushService.cs @@ -114,8 +114,7 @@ private async Task ExecuteDeleteAsync(PhoneOrderPushTask task, CancellationToken { var req = JsonSerializer.Deserialize(task.RequestJson); - await _salesClient.DeleteAiOrderAsync(req, cancellationToken).ConfigureAwait(false); - var resp =await _salesClient.DeleteAiOrderAsync(req, cancellationToken).ConfigureAwait(false); + var resp = await _salesClient.DeleteAiOrderAsync(req, cancellationToken).ConfigureAwait(false); Log.Information("Sales DeleteOrder SUCCESS. TaskId={TaskId}, Request={@Request}", task.Id, req); if (resp?.Data == null || resp.Data == Guid.Empty) diff --git a/src/SmartTalk.Core/Settings/PhoneOrder/AixvolinkPhoneOrderSetting.cs b/src/SmartTalk.Core/Settings/PhoneOrder/AixvolinkPhoneOrderSetting.cs new file mode 100644 index 000000000..e810df74d --- /dev/null +++ b/src/SmartTalk.Core/Settings/PhoneOrder/AixvolinkPhoneOrderSetting.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; + +namespace SmartTalk.Core.Settings.PhoneOrder; + +public class AixvolinkPhoneOrderSetting : IConfigurationSetting +{ + public AixvolinkPhoneOrderSetting(IConfiguration configuration) + { + DefaultAgentId = configuration.GetValue("AixvolinkPhoneOrder:DefaultAgentId"); + DefaultAssistantId = configuration.GetValue("AixvolinkPhoneOrder:DefaultAssistantId"); + } + + public int? DefaultAgentId { get; set; } + + public int? DefaultAssistantId { get; set; } +} diff --git a/src/SmartTalk.Messages/Commands/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommand.cs b/src/SmartTalk.Messages/Commands/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommand.cs new file mode 100644 index 000000000..2410f93cf --- /dev/null +++ b/src/SmartTalk.Messages/Commands/PhoneOrder/ReceiveAixvolinkPhoneOrderRecordCommand.cs @@ -0,0 +1,21 @@ +using Mediator.Net.Contracts; +using SmartTalk.Messages.Enums.PhoneOrder; + +namespace SmartTalk.Messages.Commands.PhoneOrder; + +public class ReceiveAixvolinkPhoneOrderRecordCommand : ICommand +{ + public string RecordingUrl { get; set; } + + public DateTimeOffset CallTime { get; set; } + + public string CallerNumber { get; set; } + + public string CalleeNumber { get; set; } + + public int AgentId { get; set; } + + public int? AssistantId { get; set; } + + public PhoneOrderRecordType OrderRecordType { get; set; } = PhoneOrderRecordType.InBound; +} diff --git a/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs b/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs index fa54338b2..5e555c73c 100644 --- a/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs +++ b/src/SmartTalk.Messages/Dto/Sales/GenerateAiOrdersRequestDto.cs @@ -14,6 +14,8 @@ public class AiOrderInfoDto public string SoldToId { get; set; } public int? AiAssistantId { get; set; } + + public string SalesGroup { get; set; } public DateTime DocumentDate { get; set; } @@ -43,4 +45,4 @@ public class AiOrderItemDto public bool Restored { get; set; } public bool IsTargetQuantity { get; set; } -} \ No newline at end of file +} diff --git a/src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs b/src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs new file mode 100644 index 000000000..32a2ec726 --- /dev/null +++ b/src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs @@ -0,0 +1,28 @@ +namespace SmartTalk.Messages.Dto.Sales; + +public class GetSalesGroupByPhoneNumberResponseDto +{ + public SalesGroupTableDto Table { get; set; } + + public List Rows { get; set; } = []; + + public int RowCount { get; set; } + + public int TotalRows { get; set; } +} + +public class SalesGroupTableDto +{ + public string Id { get; set; } + + public string Name { get; set; } +} + +public class SalesGroupRowDto +{ + public string PhoneNumber { get; set; } + + public string Sales { get; set; } + + public string SalesGroup { get; set; } +} diff --git a/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs new file mode 100644 index 000000000..c66bed894 --- /dev/null +++ b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs @@ -0,0 +1,129 @@ +using NSubstitute; +using Shouldly; +using SmartTalk.Core.Services.Http.Clients; +using SmartTalk.Core.Services.Sale; +using SmartTalk.Messages.Dto.Crm; +using SmartTalk.Messages.Dto.Sales; +using Xunit; + +namespace SmartTalk.UnitTests.Services.Sale; + +public class SalesCustomerMatchServiceTests +{ + private readonly ICrmClient _crmClient = Substitute.For(); + private readonly ISalesClient _salesClient = Substitute.For(); + + [Fact] + public async Task MatchCustomerAsync_ShouldReturnPhoneMatchedCustomer_WhenCrmPhoneMatched() + { + var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + + _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); + _crmClient.GetCustomersByPhoneNumberAsync( + Arg.Is(x => x.PhoneNumber == "+19164284295"), + "crm-token", + Arg.Any()) + .Returns([ + new GetCustomersPhoneNumberDataDto + { + SapId = "00012345", + CustomerName = "Test Customer" + } + ]); + _crmClient.GetCustomersByPhoneNumberAsync( + Arg.Is(x => x.PhoneNumber == "+19165550000"), + "crm-token", + Arg.Any()) + .Returns([]); + + var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", "+1 (916) 555-0000", "Test Store", ["+1 (916) 555-0000"], CancellationToken.None); + + result.SoldToId.ShouldBe("12345"); + result.SoldToIds.ShouldBe(["12345"]); + } + + [Fact] + public async Task MatchCustomerAsync_ShouldFallbackToStoreName_WhenPhoneNotMatched() + { + var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + + _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); + _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) + .Returns([]); + _crmClient.GetCustomersByRestaurantNameAsync("Lucky House", "crm-token", Arg.Any()) + .Returns([ + new GetCustomersPhoneNumberDataDto + { + SapId = "00098765", + CustomerName = "Lucky House" + } + ]); + + var result = await sut.MatchCustomerAsync("+1 916 000 0000", "+1 916 111 1111", "Lucky House", ["+1 916 111 1111"], CancellationToken.None); + + result.SoldToId.ShouldBe("98765"); + result.SoldToIds.ShouldBe(["98765"]); + } + + [Fact] + public async Task MatchCustomerAsync_ShouldFallbackToSalesGroup_WhenCustomerIdNotMatched() + { + var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + + _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); + _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) + .Returns([]); + _crmClient.GetCustomersByRestaurantNameAsync(Arg.Any(), "crm-token", Arg.Any()) + .Returns([]); + _salesClient.GetCustomerNumbersByNameAsync(Arg.Any(), Arg.Any()) + .Returns(new GetCustomerNumbersByNameResponseDto { Data = [] }); + _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + .Returns("SG-001"); + + var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", null, null, ["+1 (916) 428-4295"], CancellationToken.None); + + result.SoldToId.ShouldBeEmpty(); + result.SoldToIds.ShouldBeEmpty(); + result.SalesGroup.ShouldBe("SG-001"); + } + + [Fact] + public async Task MatchCustomerAsync_ShouldSkipCustomerMatchingAndFallbackToSalesGroup_WhenCrmUnavailable() + { + var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + + _crmClient.GetCrmTokenAsync(Arg.Any()).Returns>(_ => throw new Exception("crm unavailable")); + _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + .Returns("SG-001"); + + var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", null, "Lucky House", ["+1 (916) 428-4295"], CancellationToken.None); + + result.SoldToId.ShouldBeEmpty(); + result.SoldToIds.ShouldBeEmpty(); + result.SalesGroup.ShouldBe("SG-001"); + await _crmClient.DidNotReceive().GetCustomersByPhoneNumberAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _crmClient.DidNotReceive().GetCustomersByRestaurantNameAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task MatchCustomerAsync_ShouldTryBothPhoneNumbers_WhenFallbackToSalesGroup() + { + var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + + _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); + _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) + .Returns([]); + _crmClient.GetCustomersByRestaurantNameAsync(Arg.Any(), "crm-token", Arg.Any()) + .Returns([]); + _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + .Returns(string.Empty); + _salesClient.GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()) + .Returns("SG-002"); + + var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", "+1 (916) 555-0000", null, ["+1 (916) 428-4295", "+1 (916) 555-0000"], CancellationToken.None); + + result.SalesGroup.ShouldBe("SG-002"); + await _salesClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()); + await _salesClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()); + } +} diff --git a/src/SmartTalk.UnitTests/Services/Sale/SalesPhoneOrderPushServiceTests.cs b/src/SmartTalk.UnitTests/Services/Sale/SalesPhoneOrderPushServiceTests.cs new file mode 100644 index 000000000..f55dbeb50 --- /dev/null +++ b/src/SmartTalk.UnitTests/Services/Sale/SalesPhoneOrderPushServiceTests.cs @@ -0,0 +1,45 @@ +using NSubstitute; +using SmartTalk.Core.Domain.Sales; +using SmartTalk.Core.Services.Http.Clients; +using SmartTalk.Core.Services.Jobs; +using SmartTalk.Core.Services.PhoneOrder; +using SmartTalk.Core.Services.Sale; +using SmartTalk.Messages.Dto.Sales; +using SmartTalk.Messages.Enums.Sales; +using Xunit; + +namespace SmartTalk.UnitTests.Services.Sale; + +public class SalesPhoneOrderPushServiceTests +{ + private readonly ISalesDataProvider _salesDataProvider = Substitute.For(); + private readonly ISalesClient _salesClient = Substitute.For(); + private readonly IPhoneOrderDataProvider _phoneOrderDataProvider = Substitute.For(); + private readonly ISmartTalkBackgroundJobClient _backgroundJobClient = Substitute.For(); + + [Fact] + public async Task ExecutePhoneOrderPushTasksAsync_ShouldCallDeleteOnce_WhenTaskIsDeleteOrder() + { + var task = new PhoneOrderPushTask + { + Id = 1, + RecordId = 2, + TaskType = PhoneOrderPushTaskType.DeleteOrder, + RequestJson = """ + {"CustomerNumber":"12345","SoldToIds":"12345","DeliveryDate":"2026-05-14T00:00:00","AiAssistantId":1} + """ + }; + + var sut = new SalesPhoneOrderPushService(_salesDataProvider, _salesClient, _phoneOrderDataProvider, _backgroundJobClient); + + _salesDataProvider.GetRecordPushTaskByRecordIdAsync(task.RecordId, Arg.Any()).Returns(task); + _salesDataProvider.IsParentCompletedAsync(task.ParentRecordId, Arg.Any()).Returns(true); + _salesDataProvider.HasPendingTasksByRecordIdAsync(task.RecordId, Arg.Any()).Returns(false); + _salesClient.DeleteAiOrderAsync(Arg.Any(), Arg.Any()) + .Returns(new DeleteAiOrderResponseDto { Data = Guid.NewGuid() }); + + await sut.ExecutePhoneOrderPushTasksAsync(task.RecordId, CancellationToken.None); + + await _salesClient.Received(1).DeleteAiOrderAsync(Arg.Any(), Arg.Any()); + } +} From f7990a66885157f66b8abc8c4639fdfec5de2009 Mon Sep 17 00:00:00 2001 From: 157 Date: Wed, 27 May 2026 11:07:31 +0800 Subject: [PATCH 2/4] add crm get customer id by shop name async --- .../Services/Http/Clients/CrmClient.cs | 18 ++++++++---------- .../Services/Sale/SalesCustomerMatchService.cs | 6 +++--- .../Crm/GetCustomerIdByShopNameResponseDto.cs | 18 ++++++++++++++++++ .../Sale/SalesCustomerMatchServiceTests.cs | 10 +++++----- 4 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 src/SmartTalk.Messages/Dto/Crm/GetCustomerIdByShopNameResponseDto.cs diff --git a/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs b/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs index ac2b9cd44..cffc08cbb 100644 --- a/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs +++ b/src/SmartTalk.Core/Services/Http/Clients/CrmClient.cs @@ -18,7 +18,7 @@ public interface ICrmClient : IScopedDependency Task> GetCustomersByPhoneNumberAsync(GetCustmoersByPhoneNumberRequestDto numberRequest, string token = null, CancellationToken cancellationToken = default); - Task> GetCustomersByRestaurantNameAsync(string restaurantName, string token = null, CancellationToken cancellationToken = default); + Task> GetCustomerIdsByShopNameAsync(string shopName, CancellationToken cancellationToken = default); Task> GetCustomerContactsAsync(string customerId, string token = null, CancellationToken cancellationToken = default); @@ -27,7 +27,7 @@ public interface ICrmClient : IScopedDependency public class CrmClient : ICrmClient { - private const string CustomerByRestaurantNamePath = "/api/customer/get-customers-by-restaurant-name?restaurant_name={0}"; + private const string CustomerIdsByShopNamePath = "/api/external/get-customers-by-shop-name?shop_name={0}"; private readonly CrmSetting _crmSetting; private readonly ISmartTalkHttpClientFactory _httpClient; @@ -83,24 +83,22 @@ public async Task> GetCustomersByPhoneNumbe return result; } - public async Task> GetCustomersByRestaurantNameAsync(string restaurantName, string token = null, CancellationToken cancellationToken = default) + public async Task> GetCustomerIdsByShopNameAsync(string shopName, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(restaurantName)) + if (string.IsNullOrWhiteSpace(shopName)) return []; - token ??= await GetCrmTokenAsync(cancellationToken).ConfigureAwait(false); - var headers = new Dictionary { { "Accept", "application/json" }, - { "Authorization", $"Bearer {token}" } + { "X-API-KEY", _crmSetting.ApiKey } }; - var url = $"{_crmSetting.BaseUrl}{string.Format(CustomerByRestaurantNamePath, Uri.EscapeDataString(restaurantName))}"; + var url = $"{_crmSetting.SyncBaseUrl}{string.Format(CustomerIdsByShopNamePath, Uri.EscapeDataString(shopName))}"; - var result = await _httpClient.GetAsync>(url, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; + var result = await _httpClient.GetAsync>(url, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; - Log.Information("Found {Count} customers for restaurant {RestaurantName}", result.Count, restaurantName); + Log.Information("Found {Count} customer ids for shop {ShopName}", result.Count, shopName); return result; } diff --git a/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs index e605bfcfc..e09321987 100644 --- a/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs +++ b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs @@ -40,7 +40,7 @@ public async Task MatchCustomerAsync(string callerNumb if (phoneMatch.SoldToIds.Count > 0) return phoneMatch; - var storeMatch = await MatchByStoreNameAsync(storeName, crmToken, cancellationToken).ConfigureAwait(false); + var storeMatch = await MatchByStoreNameAsync(storeName, cancellationToken).ConfigureAwait(false); if (storeMatch.SoldToIds.Count > 0) return storeMatch; } @@ -109,7 +109,7 @@ private async Task MatchByPhonesAsync(IEnumerable MatchByStoreNameAsync(string storeName, string crmToken, CancellationToken cancellationToken) + private async Task MatchByStoreNameAsync(string storeName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(storeName)) return new SalesCustomerMatchResult(); @@ -118,7 +118,7 @@ private async Task MatchByStoreNameAsync(string storeN try { - var crmCustomers = await _crmClient.GetCustomersByRestaurantNameAsync(storeName, crmToken, cancellationToken).ConfigureAwait(false); + var crmCustomers = await _crmClient.GetCustomerIdsByShopNameAsync(storeName, cancellationToken).ConfigureAwait(false); soldToIds = crmCustomers .Select(x => NormalizeCustomerId(x.SapId)) .Where(x => !string.IsNullOrWhiteSpace(x)) diff --git a/src/SmartTalk.Messages/Dto/Crm/GetCustomerIdByShopNameResponseDto.cs b/src/SmartTalk.Messages/Dto/Crm/GetCustomerIdByShopNameResponseDto.cs new file mode 100644 index 000000000..cb5dbd6f6 --- /dev/null +++ b/src/SmartTalk.Messages/Dto/Crm/GetCustomerIdByShopNameResponseDto.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace SmartTalk.Messages.Dto.Crm; + +public class GetCustomerIdByShopNameResponseDto +{ + [JsonProperty("sap_id")] + public string SapId { get; set; } + + [JsonProperty("restaurant_name_remark")] + public string RestaurantNameRemark { get; set; } + + [JsonProperty("customer_name")] + public string CustomerName { get; set; } + + [JsonProperty("restaurant_cn_name")] + public string RestaurantCnName { get; set; } +} diff --git a/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs index c66bed894..cfe45a315 100644 --- a/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs +++ b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs @@ -50,9 +50,9 @@ public async Task MatchCustomerAsync_ShouldFallbackToStoreName_WhenPhoneNotMatch _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) .Returns([]); - _crmClient.GetCustomersByRestaurantNameAsync("Lucky House", "crm-token", Arg.Any()) + _crmClient.GetCustomerIdsByShopNameAsync("Lucky House", Arg.Any()) .Returns([ - new GetCustomersPhoneNumberDataDto + new GetCustomerIdByShopNameResponseDto { SapId = "00098765", CustomerName = "Lucky House" @@ -73,7 +73,7 @@ public async Task MatchCustomerAsync_ShouldFallbackToSalesGroup_WhenCustomerIdNo _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) .Returns([]); - _crmClient.GetCustomersByRestaurantNameAsync(Arg.Any(), "crm-token", Arg.Any()) + _crmClient.GetCustomerIdsByShopNameAsync(Arg.Any(), Arg.Any()) .Returns([]); _salesClient.GetCustomerNumbersByNameAsync(Arg.Any(), Arg.Any()) .Returns(new GetCustomerNumbersByNameResponseDto { Data = [] }); @@ -102,7 +102,7 @@ public async Task MatchCustomerAsync_ShouldSkipCustomerMatchingAndFallbackToSale result.SoldToIds.ShouldBeEmpty(); result.SalesGroup.ShouldBe("SG-001"); await _crmClient.DidNotReceive().GetCustomersByPhoneNumberAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await _crmClient.DidNotReceive().GetCustomersByRestaurantNameAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _crmClient.DidNotReceive().GetCustomerIdsByShopNameAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -113,7 +113,7 @@ public async Task MatchCustomerAsync_ShouldTryBothPhoneNumbers_WhenFallbackToSal _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) .Returns([]); - _crmClient.GetCustomersByRestaurantNameAsync(Arg.Any(), "crm-token", Arg.Any()) + _crmClient.GetCustomerIdsByShopNameAsync(Arg.Any(), Arg.Any()) .Returns([]); _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) .Returns(string.Empty); From 0dc8c088c7933c67fde27582db58f87bf816c2dc Mon Sep 17 00:00:00 2001 From: 157 Date: Fri, 29 May 2026 14:21:08 +0800 Subject: [PATCH 3/4] add get sales group by phone number async --- src/SmartTalk.Api/appsettings.json | 4 ++ .../Services/Http/Clients/DaovikaClient.cs | 48 +++++++++++++++ .../Services/Http/Clients/SalesClient.cs | 17 ------ .../Sale/SalesCustomerMatchService.cs | 8 +-- .../Settings/Daovika/DaovikaSetting.cs | 16 +++++ .../GetSalesGroupByPhoneNumberResponseDto.cs | 2 +- .../Http/Clients/DaovikaClientTests.cs | 61 +++++++++++++++++++ .../Sale/SalesCustomerMatchServiceTests.cs | 27 ++++---- 8 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 src/SmartTalk.Core/Services/Http/Clients/DaovikaClient.cs create mode 100644 src/SmartTalk.Core/Settings/Daovika/DaovikaSetting.cs rename src/SmartTalk.Messages/Dto/{Sales => Daovika}/GetSalesGroupByPhoneNumberResponseDto.cs (92%) create mode 100644 src/SmartTalk.UnitTests/Services/Http/Clients/DaovikaClientTests.cs diff --git a/src/SmartTalk.Api/appsettings.json b/src/SmartTalk.Api/appsettings.json index 7218f557f..0ae2687fc 100644 --- a/src/SmartTalk.Api/appsettings.json +++ b/src/SmartTalk.Api/appsettings.json @@ -131,6 +131,10 @@ "BaseUrl": "", "CompanyName": "" }, + "Daovika": { + "ApiKey": "", + "BaseUrl": "https://daovika.testomenow.com" + }, "SalesCustomerHabit": { "ApiKey": "", "BaseUrl": "" diff --git a/src/SmartTalk.Core/Services/Http/Clients/DaovikaClient.cs b/src/SmartTalk.Core/Services/Http/Clients/DaovikaClient.cs new file mode 100644 index 000000000..d092787b2 --- /dev/null +++ b/src/SmartTalk.Core/Services/Http/Clients/DaovikaClient.cs @@ -0,0 +1,48 @@ +using SmartTalk.Core.Ioc; +using SmartTalk.Core.Settings.Daovika; +using SmartTalk.Messages.Dto.Daovika; + +namespace SmartTalk.Core.Services.Http.Clients; + +public interface IDaovikaClient : IScopedDependency +{ + Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken); +} + +public class DaovikaClient : IDaovikaClient +{ + private const string SalesGroupTableId = "fc7a74fc-ea1f-4be1-93c3-03ed190a2c56"; + + private readonly DaovikaSetting _daovikaSetting; + private readonly ISmartTalkHttpClientFactory _httpClientFactory; + + public DaovikaClient(DaovikaSetting daovikaSetting, ISmartTalkHttpClientFactory httpClientFactory) + { + _daovikaSetting = daovikaSetting; + _httpClientFactory = httpClientFactory; + } + + public async Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + return string.Empty; + + var queryPhoneNumber = phoneNumber.Trim(); + var url = $"{_daovikaSetting.BaseUrl}/api/external/table/query" + + $"?tableId={SalesGroupTableId}" + + $"&apiKey={Uri.EscapeDataString(_daovikaSetting.ApiKey)}" + + "&field=phoneNumber&op=eq" + + $"&value={Uri.EscapeDataString(queryPhoneNumber)}" + + "&limit=1000&offset=0"; + + var headers = new Dictionary + { + { "accept", "application/json" }, + { "x-api-key", _daovikaSetting.ApiKey } + }; + + var response = await _httpClientFactory.GetAsync(url, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false); + + return response?.Rows?.FirstOrDefault()?.SalesGroup?.Trim() ?? string.Empty; + } +} diff --git a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs index 092f2e898..2223f1ec1 100644 --- a/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs +++ b/src/SmartTalk.Core/Services/Http/Clients/SalesClient.cs @@ -19,8 +19,6 @@ public interface ISalesClient : IScopedDependency Task GetCustomerNumbersByNameAsync(GetCustomerNumbersByNameRequestDto request, CancellationToken cancellationToken); Task GetCustomerLevel5HabitAsync(GetCustomerLevel5HabitRequstDto request, CancellationToken cancellationToken); - - Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken); Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken); @@ -109,21 +107,6 @@ public async Task GetCustomerLevel5HabitAsync return await _httpClientFactory.PostAsJsonAsync($"{_salesCustomerHabitSetting.BaseUrl}/api/CustomerInfo/QueryHistoryCustomerLevel5Habit", request, headers: header, cancellationToken: cancellationToken).ConfigureAwait(false); } - public async Task GetSalesGroupByPhoneNumberAsync(string phoneNumber, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(phoneNumber)) - return string.Empty; - - var url = $"{_salesSetting.BaseUrl}/api/external/table/query?tableId=d45f6888-4855-4114-8a9c-1ccc641e43ac" - + $"&apiKey={Uri.EscapeDataString(_salesSetting.ApiKey)}" - + "&field=phoneNumber&op=eq" - + $"&value={Uri.EscapeDataString(phoneNumber)}"; - - var response = await _httpClientFactory.GetAsync(url, cancellationToken: cancellationToken).ConfigureAwait(false); - - return response?.Rows?.FirstOrDefault()?.SalesGroup?.Trim() ?? string.Empty; - } - public async Task DeleteAiOrderAsync(DeleteAiOrderRequestDto request, CancellationToken cancellationToken) { return await _httpClientFactory.PostAsJsonAsync($"{_salesSetting.BaseUrl}/api/SalesOrder/DeleteAiOrder", request, headers: _headers, cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs index e09321987..1cb220850 100644 --- a/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs +++ b/src/SmartTalk.Core/Services/Sale/SalesCustomerMatchService.cs @@ -22,12 +22,12 @@ public class SalesCustomerMatchResult public class SalesCustomerMatchService : ISalesCustomerMatchService { private readonly ICrmClient _crmClient; - private readonly ISalesClient _salesClient; + private readonly IDaovikaClient _daovikaClient; - public SalesCustomerMatchService(ICrmClient crmClient, ISalesClient salesClient) + public SalesCustomerMatchService(ICrmClient crmClient, IDaovikaClient daovikaClient) { _crmClient = crmClient; - _salesClient = salesClient; + _daovikaClient = daovikaClient; } public async Task MatchCustomerAsync(string callerNumber, string calleeNumber, string storeName, IEnumerable salesPhoneNumbers, CancellationToken cancellationToken) @@ -149,7 +149,7 @@ private async Task MatchSalesGroupByPhonesAsync(IEnumerable phon { try { - var salesGroup = await _salesClient.GetSalesGroupByPhoneNumberAsync(phoneNumber, cancellationToken).ConfigureAwait(false); + var salesGroup = await _daovikaClient.GetSalesGroupByPhoneNumberAsync(phoneNumber, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(salesGroup)) return salesGroup.Trim(); } diff --git a/src/SmartTalk.Core/Settings/Daovika/DaovikaSetting.cs b/src/SmartTalk.Core/Settings/Daovika/DaovikaSetting.cs new file mode 100644 index 000000000..11dd4e1ab --- /dev/null +++ b/src/SmartTalk.Core/Settings/Daovika/DaovikaSetting.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; + +namespace SmartTalk.Core.Settings.Daovika; + +public class DaovikaSetting : IConfigurationSetting +{ + public DaovikaSetting(IConfiguration configuration) + { + BaseUrl = configuration.GetValue("Daovika:BaseUrl"); + ApiKey = configuration.GetValue("Daovika:ApiKey"); + } + + public string BaseUrl { get; set; } + + public string ApiKey { get; set; } +} diff --git a/src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs b/src/SmartTalk.Messages/Dto/Daovika/GetSalesGroupByPhoneNumberResponseDto.cs similarity index 92% rename from src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs rename to src/SmartTalk.Messages/Dto/Daovika/GetSalesGroupByPhoneNumberResponseDto.cs index 32a2ec726..4cc74766e 100644 --- a/src/SmartTalk.Messages/Dto/Sales/GetSalesGroupByPhoneNumberResponseDto.cs +++ b/src/SmartTalk.Messages/Dto/Daovika/GetSalesGroupByPhoneNumberResponseDto.cs @@ -1,4 +1,4 @@ -namespace SmartTalk.Messages.Dto.Sales; +namespace SmartTalk.Messages.Dto.Daovika; public class GetSalesGroupByPhoneNumberResponseDto { diff --git a/src/SmartTalk.UnitTests/Services/Http/Clients/DaovikaClientTests.cs b/src/SmartTalk.UnitTests/Services/Http/Clients/DaovikaClientTests.cs new file mode 100644 index 000000000..649c75aa3 --- /dev/null +++ b/src/SmartTalk.UnitTests/Services/Http/Clients/DaovikaClientTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Shouldly; +using SmartTalk.Core.Services.Http; +using SmartTalk.Core.Services.Http.Clients; +using SmartTalk.Core.Settings.Daovika; +using SmartTalk.Messages.Dto.Daovika; +using Xunit; + +namespace SmartTalk.UnitTests.Services.Http.Clients; + +public class DaovikaClientTests +{ + private readonly ISmartTalkHttpClientFactory _httpClientFactory = Substitute.For(); + + [Fact] + public async Task GetSalesGroupByPhoneNumberAsync_ShouldQueryDaovikaTableWithPhone() + { + var setting = BuildSetting(); + var sut = new DaovikaClient(setting, _httpClientFactory); + + _httpClientFactory.GetAsync( + Arg.Is(url => + url.Contains("tableId=fc7a74fc-ea1f-4be1-93c3-03ed190a2c56", StringComparison.Ordinal) && + url.Contains("apiKey=api-key", StringComparison.Ordinal) && + url.Contains("field=phoneNumber&op=eq", StringComparison.Ordinal) && + url.Contains("value=%2B19164284295", StringComparison.Ordinal) && + url.Contains("limit=1000&offset=0", StringComparison.Ordinal)), + Arg.Any(), + headers: Arg.Is>(headers => + headers["accept"] == "application/json" && + headers["x-api-key"] == "api-key")) + .Returns(new GetSalesGroupByPhoneNumberResponseDto + { + Rows = + [ + new SalesGroupRowDto + { + SalesGroup = " SG-001 " + } + ] + }); + + var result = await sut.GetSalesGroupByPhoneNumberAsync("+19164284295", CancellationToken.None); + + result.ShouldBe("SG-001"); + } + + private static DaovikaSetting BuildSetting() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Daovika:BaseUrl", "https://daovika.example.com" }, + { "Daovika:ApiKey", "api-key" } + }) + .Build(); + + return new DaovikaSetting(configuration); + } +} diff --git a/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs index cfe45a315..16f91db94 100644 --- a/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs +++ b/src/SmartTalk.UnitTests/Services/Sale/SalesCustomerMatchServiceTests.cs @@ -3,7 +3,6 @@ using SmartTalk.Core.Services.Http.Clients; using SmartTalk.Core.Services.Sale; using SmartTalk.Messages.Dto.Crm; -using SmartTalk.Messages.Dto.Sales; using Xunit; namespace SmartTalk.UnitTests.Services.Sale; @@ -11,12 +10,12 @@ namespace SmartTalk.UnitTests.Services.Sale; public class SalesCustomerMatchServiceTests { private readonly ICrmClient _crmClient = Substitute.For(); - private readonly ISalesClient _salesClient = Substitute.For(); + private readonly IDaovikaClient _daovikaClient = Substitute.For(); [Fact] public async Task MatchCustomerAsync_ShouldReturnPhoneMatchedCustomer_WhenCrmPhoneMatched() { - var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + var sut = new SalesCustomerMatchService(_crmClient, _daovikaClient); _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync( @@ -45,7 +44,7 @@ public async Task MatchCustomerAsync_ShouldReturnPhoneMatchedCustomer_WhenCrmPho [Fact] public async Task MatchCustomerAsync_ShouldFallbackToStoreName_WhenPhoneNotMatched() { - var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + var sut = new SalesCustomerMatchService(_crmClient, _daovikaClient); _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) @@ -68,16 +67,14 @@ public async Task MatchCustomerAsync_ShouldFallbackToStoreName_WhenPhoneNotMatch [Fact] public async Task MatchCustomerAsync_ShouldFallbackToSalesGroup_WhenCustomerIdNotMatched() { - var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + var sut = new SalesCustomerMatchService(_crmClient, _daovikaClient); _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) .Returns([]); _crmClient.GetCustomerIdsByShopNameAsync(Arg.Any(), Arg.Any()) .Returns([]); - _salesClient.GetCustomerNumbersByNameAsync(Arg.Any(), Arg.Any()) - .Returns(new GetCustomerNumbersByNameResponseDto { Data = [] }); - _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + _daovikaClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) .Returns("SG-001"); var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", null, null, ["+1 (916) 428-4295"], CancellationToken.None); @@ -90,10 +87,10 @@ public async Task MatchCustomerAsync_ShouldFallbackToSalesGroup_WhenCustomerIdNo [Fact] public async Task MatchCustomerAsync_ShouldSkipCustomerMatchingAndFallbackToSalesGroup_WhenCrmUnavailable() { - var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + var sut = new SalesCustomerMatchService(_crmClient, _daovikaClient); _crmClient.GetCrmTokenAsync(Arg.Any()).Returns>(_ => throw new Exception("crm unavailable")); - _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + _daovikaClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) .Returns("SG-001"); var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", null, "Lucky House", ["+1 (916) 428-4295"], CancellationToken.None); @@ -108,22 +105,22 @@ public async Task MatchCustomerAsync_ShouldSkipCustomerMatchingAndFallbackToSale [Fact] public async Task MatchCustomerAsync_ShouldTryBothPhoneNumbers_WhenFallbackToSalesGroup() { - var sut = new SalesCustomerMatchService(_crmClient, _salesClient); + var sut = new SalesCustomerMatchService(_crmClient, _daovikaClient); _crmClient.GetCrmTokenAsync(Arg.Any()).Returns("crm-token"); _crmClient.GetCustomersByPhoneNumberAsync(Arg.Any(), "crm-token", Arg.Any()) .Returns([]); _crmClient.GetCustomerIdsByShopNameAsync(Arg.Any(), Arg.Any()) .Returns([]); - _salesClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) + _daovikaClient.GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()) .Returns(string.Empty); - _salesClient.GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()) + _daovikaClient.GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()) .Returns("SG-002"); var result = await sut.MatchCustomerAsync("+1 (916) 428-4295", "+1 (916) 555-0000", null, ["+1 (916) 428-4295", "+1 (916) 555-0000"], CancellationToken.None); result.SalesGroup.ShouldBe("SG-002"); - await _salesClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()); - await _salesClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()); + await _daovikaClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19164284295", Arg.Any()); + await _daovikaClient.Received(1).GetSalesGroupByPhoneNumberAsync("+19165550000", Arg.Any()); } } From 384cdf529aca9fc91ef9b969a6801687a1569442 Mon Sep 17 00:00:00 2001 From: 157 Date: Fri, 29 May 2026 14:24:29 +0800 Subject: [PATCH 4/4] Update appsettings.json --- src/SmartTalk.Api/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SmartTalk.Api/appsettings.json b/src/SmartTalk.Api/appsettings.json index 0ae2687fc..bf1aa8ab9 100644 --- a/src/SmartTalk.Api/appsettings.json +++ b/src/SmartTalk.Api/appsettings.json @@ -133,7 +133,7 @@ }, "Daovika": { "ApiKey": "", - "BaseUrl": "https://daovika.testomenow.com" + "BaseUrl": "" }, "SalesCustomerHabit": { "ApiKey": "",