Compare commits

..

2 Commits

Author SHA1 Message Date
DeepnaPooja a2d9ba4183 Add testbug.md 2026-09-01 17:21:31 +05:30
DeepnaPooja dd1d93d032 Add validations 2026-09-01 16:11:36 +05:30
240 changed files with 4245 additions and 29265 deletions
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aa7ea37ab61a149a38d83283c5dabd76bf5a39b3")]
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
5c052f1f25bc37b6a51a3abe0e2b79d99d21422d5ab89151faea450c0ddb9cc0
@@ -11,7 +11,7 @@ build_property.EnforceExtendedAnalyzerRules =
build_property.EntryPointFilePath =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Distribution.Tests
build_property.ProjectDir = D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\
build_property.ProjectDir = C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 9.0
@@ -1,23 +1,23 @@
{
"format": 1,
"restore": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
},
"projects": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectName": "Distribution.Tests",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -26,7 +26,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -34,8 +33,8 @@
"framework": "net9.0",
"targetAlias": "net9.0",
"projectReferences": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
}
}
}
@@ -50,7 +49,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -98,24 +97,24 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
},
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
"projectName": "Distribution",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -124,7 +123,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -144,7 +142,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -193,7 +191,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
}
@@ -5,12 +5,12 @@
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\dell\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\lenovo\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\dell\.nuget\packages\" />
<SourceRoot Include="C:\Users\lenovo\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
@@ -22,7 +22,7 @@
<Import Project="$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props" Condition="Exists('$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props')" />
</ImportGroup>
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\dell\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\dell\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\lenovo\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\lenovo\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
</PropertyGroup>
</Project>
+10 -11
View File
@@ -2542,23 +2542,23 @@
]
},
"packageFolders": {
"C:\\Users\\dell\\.nuget\\packages\\": {},
"C:\\Users\\lenovo\\.nuget\\packages\\": {},
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectName": "Distribution.Tests",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -2567,7 +2567,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -2575,8 +2574,8 @@
"framework": "net9.0",
"targetAlias": "net9.0",
"projectReferences": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
}
}
}
@@ -2591,7 +2590,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -2639,7 +2638,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
}
+50 -50
View File
@@ -1,57 +1,57 @@
{
"version": 2,
"dgSpecHash": "JPCR3IFZHKY=",
"dgSpecHash": "XdH8cqlThLk=",
"success": true,
"projectFilePath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectFilePath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"expectedPackageFiles": [
"C:\\Users\\dell\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
"C:\\Users\\lenovo\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
],
"logs": []
}
+65 -148
View File
@@ -17,10 +17,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -62,11 +61,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// --- Composite PKs ---
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -80,10 +79,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<LoadingSheetOrder>()
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
// --- Unique indexes ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
@@ -93,20 +93,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
// --- User ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Vehicle ---
modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>()
.WithMany()
@@ -114,7 +108,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- DeliveryRun ---
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
@@ -122,97 +120,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
// --- ShopPO ---
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(p => p.FK_StoreId)
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
// InvoiceId is a plain column — no FK constraint
modelBuilder.Entity<ShopPO>()
.Property(p => p.InvoiceId)
.HasColumnName("InvoiceId");
// --- ShopPOItem ---
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.ShopPO)
.WithMany(p => p.Items)
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Invoice ---
modelBuilder.Entity<Invoice>()
.HasOne(i => i.ShopPO)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
.HasForeignKey(p => p.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheet ---
modelBuilder.Entity<LoadingSheet>()
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetOrder ---
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.LoadingSheet)
.WithMany(s => s.Orders)
.HasForeignKey(lo => lo.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.ShopPO)
.WithMany()
.HasForeignKey(lo => lo.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetItem ---
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.LoadingSheet)
.WithMany(s => s.Items)
.HasForeignKey(i => i.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Warehouse)
.WithMany()
.HasForeignKey(i => i.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.SetNull)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SchemeRedemption ---
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
@@ -221,11 +152,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
@@ -233,8 +163,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
@@ -263,7 +191,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
// --- PurchaseOrder ---
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
@@ -283,11 +227,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
@@ -312,14 +251,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
@@ -338,15 +269,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
@@ -370,16 +308,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
@@ -432,8 +366,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
@@ -441,13 +373,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.ShopPO)
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_ShopPOId)
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -470,19 +400,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
+3 -3
View File
@@ -80,15 +80,15 @@ public static class CommerceSeed
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
+72 -54
View File
@@ -8,17 +8,15 @@ public static class OperationsDataSeed
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -33,12 +31,16 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500,
Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
@@ -46,71 +48,86 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ActiveDeliveryRunId,
Status = RideStatus.Open,
RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300,
Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPO>().HasData(
new ShopPO
modelBuilder.Entity<Sale>().HasData(
new Sale
{
Id = ShopPO1Id,
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
RaisedById = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
Status = SaleStatus.Completed,
PaymentStatus = PaymentStatus.Paid,
FulfillImmediately = false,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new ShopPO
new Order
{
Id = ShopPO2Id,
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
TotalAmount = 1200m,
Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
Status = OrderStatus.Pending,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPOItem>().HasData(
new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<Invoice>().HasData(
new Invoice
{
Id = Invoice1Id,
FK_ShopPOId = ShopPO1Id,
Status = InvoiceStatus.Finalized,
PaymentStatus = PaymentStatus.Paid,
CreatedBy = MasterDataSeed.SalesRep1UserId,
PrintedAt = new DateTime(2026, 8, 25, 8, 45, 0),
PrintedBy = MasterDataSeed.SalesRep1UserId,
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0),
FinalizedBy = MasterDataSeed.Supervisor1UserId,
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true
}
modelBuilder.Entity<OrderItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<LoadingSheet>().HasData(
@@ -118,19 +135,20 @@ public static class OperationsDataSeed
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
Status = LoadingSheetStatus.Confirmed,
PopulatedBy = MasterDataSeed.Supervisor1UserId,
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Status = LoadingSheetStatus.ReceiptConfirmed,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
}
);
modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 50m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
);
modelBuilder.Entity<DeliveryTask>().HasData(
@@ -138,7 +156,7 @@ public static class OperationsDataSeed
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_ShopPOId = ShopPO2Id,
FK_OrderId = Order2Id,
Sequence = 1,
Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false,
@@ -1,4 +1,3 @@
using Distribution.Features.Stock;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -81,9 +80,9 @@ public static class OperationsExtrasSeed
);
modelBuilder.Entity<StockMovement>().HasData(
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = null, FromLocationType = null, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
+1 -1
View File
@@ -1,5 +1,5 @@
namespace Distribution.Features.Brand;
public record CreateBrandRequest(string Name);
public record UpdateBrandRequest(string Name, bool IsActive = true);
public record UpdateBrandRequest(string Name);
public record BrandResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
+4 -7
View File
@@ -5,16 +5,15 @@ namespace Distribution.Features.Brand;
public class BrandService(IBrandRepository repo, IUnitOfWork uow)
{
public async Task<List<BrandResponse>> GetAllAsync() =>
// Deleted brands stay IsActive=false but remain visible showing Inactive — delete
// never removes the row, only marks it (same rule as Items/Stores/Categories).
(await repo.GetAllAsync())
.Where(b => b.IsActive)
.Select(b => new BrandResponse(b.Id, b.Name, b.IsActive, b.CreatedAt))
.ToList();
public async Task<BrandResponse?> GetByIdAsync(Guid id)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null) return null;
if (brand is null || !brand.IsActive) return null;
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
}
@@ -30,10 +29,9 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
public async Task<BrandResponse?> UpdateAsync(Guid id, UpdateBrandRequest request, CancellationToken ct = default)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null) return null;
if (brand is null || !brand.IsActive) return null;
if (brand.ExternalErpId.HasValue) return null;
brand.Name = request.Name;
brand.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -42,11 +40,10 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by items still referencing this brand, same rule
// as Categories.
var brand = await repo.GetByIdAsync(id);
if (brand is null || !brand.IsActive) return false;
if (brand.ExternalErpId.HasValue) return false;
if (await repo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { repo.Remove(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -1,9 +1,9 @@
namespace Distribution.Features.Category;
public record CreateCategoryRequest(string Name);
public record UpdateCategoryRequest(string Name, bool IsActive = true);
public record UpdateCategoryRequest(string Name);
public record CreateSubCategoryRequest(string Name);
public record UpdateSubCategoryRequest(string Name, bool IsActive = true);
public record UpdateSubCategoryRequest(string Name);
public record SubCategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record CategorySummaryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
@@ -4,27 +4,18 @@ namespace Distribution.Features.Category;
public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepository subCategoryRepo, IUnitOfWork uow)
{
public async Task<List<CategoryResponse>> GetAllAsync()
{
// Deleted categories/sub-categories stay IsActive=false but remain visible showing
// Inactive — delete never removes the row, only marks it (same rule as Orders/Stores/Items).
var categoryList = await categoryRepo.GetAllAsync();
var result = new List<CategoryResponse>();
foreach (var category in categoryList)
{
var subs = (await subCategoryRepo.GetByCategoryIdAsync(category.Id))
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
.ToList();
result.Add(new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs));
}
return result;
}
public async Task<List<CategorySummaryResponse>> GetAllAsync() =>
(await categoryRepo.GetAllAsync())
.Where(c => c.IsActive)
.Select(c => new CategorySummaryResponse(c.Id, c.Name, c.IsActive, c.CreatedAt))
.ToList();
public async Task<CategoryResponse?> GetByIdAsync(Guid id)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null) return null;
if (category is null || !category.IsActive) return null;
var subs = (await subCategoryRepo.GetByCategoryIdAsync(id))
.Where(s => s.IsActive)
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
.ToList();
return new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs);
@@ -42,10 +33,9 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<CategorySummaryResponse?> UpdateAsync(Guid id, UpdateCategoryRequest request, CancellationToken ct = default)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null) return null;
if (category is null || !category.IsActive) return null;
if (category.ExternalErpId.HasValue) return null;
category.Name = request.Name;
category.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { categoryRepo.Update(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -54,11 +44,11 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by child sub-categories/items, same rule as
// Items/Stores/Orders. The row stays visible showing Inactive.
var category = await categoryRepo.GetByIdAsync(id);
if (category is null || !category.IsActive) return false;
if (category.ExternalErpId.HasValue) return false;
if (await categoryRepo.HasActiveSubCategoriesAsync(id)) return false;
if (await categoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { categoryRepo.Remove(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -79,10 +69,9 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<SubCategoryResponse?> UpdateSubCategoryAsync(Guid id, UpdateSubCategoryRequest request, CancellationToken ct = default)
{
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null) return null;
if (sub is null || !sub.IsActive) return null;
if (sub.ExternalErpId.HasValue) return null;
sub.Name = request.Name;
sub.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { subCategoryRepo.Update(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -91,10 +80,10 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<bool> DeleteSubCategoryAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by child items, same rule as the parent category.
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null || !sub.IsActive) return false;
if (sub.ExternalErpId.HasValue) return false;
if (await subCategoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { subCategoryRepo.Remove(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -6,10 +6,6 @@ namespace Distribution.Features.Claims;
[ApiController]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -28,25 +24,13 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPut("api/claims/{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/claims/{id:guid}/settle")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.SettledByUserId;
if (userId is null) return BadRequest(new { error = "SettledByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SettleAsync(id, request, userId.Value, ct);
var result = await service.SettleAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
+1 -8
View File
@@ -4,14 +4,7 @@ namespace Distribution.Features.Claims;
public record CreateClaimRequest(Guid AgencyUserId, string Details);
// Only editable while the claim is still Open — once settled, the settlement amount is a
// financial record and the claim details it was settled against should not silently change.
public record UpdateClaimRequest(Guid AgencyUserId, string Details);
public record SettleClaimRequest(
decimal SettlementAmount,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? SettledByUserId = null);
public record SettleClaimRequest(decimal SettlementAmount);
public record ClaimResponse(
Guid Id,
@@ -8,12 +8,6 @@ public class ClaimRepository(AppDBContext context) : IClaimRepository
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.Claim>> GetAllAsync() =>
await context.Claims
.Where(c => c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
await context.Claims
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
@@ -15,9 +15,6 @@ public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
return Map(c);
}
public async Task<List<ClaimResponse>> GetAllAsync() =>
(await repo.GetAllAsync()).Select(Map).ToList();
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
@@ -43,26 +40,6 @@ public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
return Map(claim);
}
public async Task<ClaimResponse> UpdateAsync(Guid id, UpdateClaimRequest request, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
if (claim is null || !claim.IsActive)
throw new InvalidOperationException("Claim not found.");
if (claim.Status == ClaimStatus.Settled)
throw new InvalidOperationException("Cannot edit a claim that has already been settled.");
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
claim.FK_AgencyUserId = request.AgencyUserId;
claim.Details = request.Details;
await uow.BeginAsync(ct);
try { repo.Update(claim); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
@@ -3,7 +3,6 @@ namespace Distribution.Features.Claims;
public interface IClaimRepository
{
Task<Models.Claim?> GetByIdAsync(Guid id);
Task<List<Models.Claim>> GetAllAsync();
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
Task AddAsync(Models.Claim entity);
void Update(Models.Claim entity);
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_ShopPOId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -8,27 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
@@ -1,5 +1,6 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,22 +10,23 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_ShopPOId,
t.ShopPO.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(),
t.CreatedAt);
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
@@ -57,16 +103,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items)
{
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > shopPOItem.Quantity)
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
@@ -89,7 +135,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -97,31 +143,29 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var shopPO = task.ShopPO;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
shopPO.FulfilledAt = DateTime.Now;
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (availableQuantity < item.QuantityFulfilled)
throw new InvalidOperationException(
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
vehicleBalance!.Quantity -= item.QuantityFulfilled;
vehicleBalance.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
@@ -130,16 +174,23 @@ public class DeliveryTaskService(
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_ShopPOId
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
shopPORepo.Update(shopPO);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
+4 -8
View File
@@ -26,14 +26,11 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
[HttpPost("api/goods-receipts")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
@@ -46,11 +43,10 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId.Value, ct);
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
+2 -11
View File
@@ -2,18 +2,9 @@ namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
public record CreateGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
-2
View File
@@ -124,8 +124,6 @@ public class GrnService(
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
@@ -1,13 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice);
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,22 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
string StoreName,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
DateTime? PrintedAt,
Guid? PrintedBy,
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -1,47 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) =>
await context.Invoices
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status));
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
}
@@ -1,33 +0,0 @@
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.IsActive, i.CreatedAt);
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ public record UpdateItemRequest(
Guid? SubCategoryId,
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
bool IsActive = true);
decimal? SalePrice);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
+1 -3
View File
@@ -20,14 +20,12 @@ public class ItemRepository(AppDBContext context) : IItemRepository
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
{
// Deleted items stay IsActive=false but remain visible with an Inactive status —
// Delete never removes the row, only marks it (same rule as Orders/Stores).
var query = context.Items
.Include(i => i.Category)
.Include(i => i.SubCategory)
.Include(i => i.Brand)
.Include(i => i.BaseUom)
.AsQueryable();
.Where(i => i.IsActive);
if (categoryId.HasValue) query = query.Where(i => i.FK_CategoryId == categoryId);
if (subCategoryId.HasValue) query = query.Where(i => i.FK_SubCategoryId == subCategoryId);
+2 -3
View File
@@ -32,7 +32,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
public async Task<ItemDetailResponse?> GetByIdAsync(Guid id)
{
var item = await repo.GetByIdAsync(id);
if (item is null) return null;
if (item is null || !item.IsActive) return null;
return MapDetail(item);
}
@@ -74,7 +74,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
public async Task<ItemDetailResponse?> UpdateAsync(Guid id, UpdateItemRequest request, CancellationToken ct = default)
{
var item = await repo.GetByIdAsync(id);
if (item is null) return null;
if (item is null || !item.IsActive) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
item.Name = request.Name;
@@ -84,7 +84,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
item.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(item); await uow.CommitAsync(ct); }
@@ -8,6 +8,7 @@ public interface ILoadingSheetRepository
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -29,10 +29,9 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
[HttpPost]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(request, userId, ct);
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -51,13 +50,90 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
[HttpPost("{id:guid}/confirm-dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmAsync(id, userId, ct);
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -5,27 +5,37 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
Guid PopulatedBy,
DateTime PopulatedAt,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId);
public record LoadingSheetOrderResponse(Guid ShopPOId);
decimal? QuantityReceived,
decimal? QuantityResolved);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -8,20 +8,20 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
@@ -36,6 +36,19 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -24,7 +24,7 @@ public class LoadingSheetService(
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -34,16 +34,22 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has a Planned loading sheet");
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
Status = LoadingSheetStatus.Planned,
PopulatedBy = userId,
PopulatedAt = DateTime.Now
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
@@ -61,8 +67,8 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
await uow.BeginAsync(ct);
try
@@ -72,8 +78,6 @@ public class LoadingSheetService(
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
FK_WarehouseId = i.FK_WarehouseId,
FK_ShopPOItemId = i.FK_ShopPOItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
@@ -84,91 +88,289 @@ public class LoadingSheetService(
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
sheet.Status = LoadingSheetStatus.Confirmed;
sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
warehouseBalance.Quantity -= item.QuantityDispatched;
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.FK_ItemId,
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = item.FK_WarehouseId,
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheetId,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}
@@ -0,0 +1,15 @@
namespace Distribution.Features.Order;
public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
public class OrderController(OrderService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
+21
View File
@@ -0,0 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderRequest(Guid SaleId);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,49 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
public class OrderRepository(AppDBContext context) : IOrderRepository
{
public async Task<Models.Order?> GetByIdAsync(Guid id) =>
await context.Orders.FindAsync(id);
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
public void Update(Models.Order entity) =>
context.Orders.Update(entity);
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId) =>
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).ToListAsync();
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
+154
View File
@@ -0,0 +1,154 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Order;
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
orders = orders.Where(o => accessibleStoreIds.Contains(o.FK_StoreId)).ToList();
}
var result = new List<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
public async Task<OrderResponse?> GetByIdAsync(Guid id)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
public async Task<List<OrderResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var orders = await repo.GetByStoreIdAsync(storeId);
var result = new List<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/payments")]
public class FlatPaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? storeId,
[FromQuery] Guid? saleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
{
try
{
var inner = new CreatePaymentRequest(request.Amount, request.Method);
var result = await service.CreateAsync(request.SaleId, inner, ct);
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,13 +1,12 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<Models.Payment?> GetByIdAsync(Guid id);
Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize);
Task<int> GetCountAsync(Guid? invoiceId);
Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId);
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
void Update(Models.Payment payment);
}
@@ -3,22 +3,22 @@
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/invoices/{invoiceId:guid}/payments")]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetByInvoice(Guid invoiceId)
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetByInvoiceIdAsync(invoiceId);
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct)
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(invoiceId, request, ct);
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
+6 -6
View File
@@ -1,15 +1,15 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(
decimal Amount,
PaymentMethod Method,
Guid? CollectedByUserId = null);
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid InvoiceId,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -9,27 +9,29 @@ public class PaymentRepository(AppDBContext context) : IPaymentRepository
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize) =>
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
await context.Payments
.Where(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId))
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? invoiceId) =>
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
await context.Payments
.CountAsync(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId));
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
public async Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId) =>
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_InvoiceId == invoiceId && p.IsActive)
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
public void Update(Models.Payment payment) =>
context.Payments.Update(payment);
}
+34 -19
View File
@@ -1,4 +1,5 @@
using Distribution.Features.Invoice;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -7,35 +8,48 @@ namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
IInvoiceRepository invoiceRepo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
var total = await repo.GetCountAsync(storeId, saleId);
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
var payments = await repo.GetByInvoiceIdAsync(invoiceId);
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
{
var payment = await repo.GetByIdAsync(id);
return payment is null ? null : ToResponse(payment);
}
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct = default)
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_InvoiceId = invoiceId,
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
@@ -47,16 +61,17 @@ public class PaymentService(
{
await repo.AddAsync(payment);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
invoice.PaymentStatus = total == 0
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < invoice.TotalAmount
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
invoiceRepo.Update(invoice);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -10,13 +10,6 @@ public class PriceListController(PriceListService service) : ControllerBase
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("resolve")]
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
{
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -69,14 +62,7 @@ public class PriceListController(PriceListService service) : ControllerBase
[HttpDelete("{id:guid}/items/{sku}")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
try
{
var result = await service.RemoveItemAsync(id, sku, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
var result = await service.RemoveItemAsync(id, sku, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -2,30 +2,16 @@ namespace Distribution.Features.PriceList;
public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo, bool IsActive);
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpsertPriceListItemRequest(
decimal Price,
decimal? MinPrice = null,
decimal? RetailPrice = null,
decimal? AgencyMarginPercent = null);
public record UpsertPriceListItemRequest(decimal Price, decimal? MinPrice = null);
public record PriceListItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
decimal Price,
decimal? MinPrice,
decimal? RetailPrice,
decimal? AgencyMarginPercent);
public record PriceListItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, decimal Price, decimal? MinPrice);
/// <summary>
/// A resolved price with its negotiable floor, plus the informational retail price and agency
/// margin when the resolution came from an actual PriceListItem (both null on the Item.SalePrice
/// fallback, since neither is tracked at the item level).
/// A resolved price with its negotiable floor. MinPrice null = no negotiation, Price is fixed.
/// </summary>
public record PriceResolution(decimal Price, decimal? MinPrice, decimal? RetailPrice, decimal? AgencyMarginPercent);
public record PriceResolution(decimal Price, decimal? MinPrice);
public record PriceListResponse(
Guid Id,
@@ -13,7 +13,9 @@ public class PriceListRepository(AppDBContext context) : IPriceListRepository
public async Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null)
{
var query = context.PriceLists.Include(pl => pl.Region).AsQueryable();
var query = context.PriceLists
.Include(pl => pl.Region)
.Where(pl => pl.IsActive);
if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId);
@@ -1,13 +1,12 @@
using Distribution.Features.Item;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
namespace Distribution.Features.PriceList;
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IStoreRepository storeRepo, IUnitOfWork uow)
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IUnitOfWork uow)
{
private static PriceListItemResponse MapItem(Models.PriceListItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice, i.RetailPrice, i.AgencyMarginPercent);
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice);
private static PriceListResponse MapDetail(Models.PriceList pl) =>
new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name,
@@ -25,7 +24,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
public async Task<PriceListResponse?> GetByIdAsync(Guid id)
{
var pl = await repo.GetByIdAsync(id);
if (pl is null) return null;
if (pl is null || !pl.IsActive) return null;
return MapDetail(pl);
}
@@ -49,7 +48,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
public async Task<PriceListResponse?> UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(id);
if (priceList is null) return null;
if (priceList is null || !priceList.IsActive) return null;
foreach (var item in priceList.Items.Where(i => i.IsActive))
{
@@ -62,7 +61,6 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
priceList.FK_RegionId = request.RegionId;
priceList.EffectiveFrom = request.EffectiveFrom;
priceList.EffectiveTo = request.EffectiveTo;
priceList.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(priceList); await uow.CommitAsync(ct); }
@@ -92,9 +90,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
public async Task<PriceListResponse?> UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(priceListId);
if (priceList is null) return null;
if (!priceList.IsActive)
throw new InvalidOperationException("Cannot price items on an inactive price list. Reactivate it first.");
if (priceList is null || !priceList.IsActive) return null;
var item = await itemRepo.GetBySkuAsync(sku);
if (item is null) return null;
@@ -103,13 +99,6 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
throw new InvalidOperationException(
$"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'.");
if (request.RetailPrice.HasValue && request.RetailPrice.Value < request.Price)
throw new InvalidOperationException(
$"RetailPrice ({request.RetailPrice}) cannot be less than Price ({request.Price}) for item '{sku}' — the store would sell at a loss.");
if (request.AgencyMarginPercent is < 0 or > 100)
throw new InvalidOperationException("AgencyMarginPercent must be between 0 and 100.");
if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId))
throw new InvalidOperationException(
$"Overlapping price list already exists for item '{sku}' in the same region for this date range.");
@@ -126,17 +115,13 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
FK_PriceListId = priceListId,
FK_ItemId = item.Id,
Price = request.Price,
MinPrice = request.MinPrice,
RetailPrice = request.RetailPrice,
AgencyMarginPercent = request.AgencyMarginPercent
MinPrice = request.MinPrice
});
}
else
{
existing.Price = request.Price;
existing.MinPrice = request.MinPrice;
existing.RetailPrice = request.RetailPrice;
existing.AgencyMarginPercent = request.AgencyMarginPercent;
repo.UpdateItem(existing);
}
await uow.CommitAsync(ct);
@@ -149,9 +134,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
public async Task<PriceListResponse?> RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(priceListId);
if (priceList is null) return null;
if (!priceList.IsActive)
throw new InvalidOperationException("Cannot remove items from an inactive price list. Reactivate it first.");
if (priceList is null || !priceList.IsActive) return null;
var item = await itemRepo.GetBySkuAsync(sku);
if (item is null) return null;
@@ -177,36 +160,12 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
if (regionId.HasValue)
{
var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate);
if (regionMatches.Count > 0)
{
var m = regionMatches[0];
return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent);
}
if (regionMatches.Count > 0) return new PriceResolution(regionMatches[0].Price, regionMatches[0].MinPrice);
}
var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate);
if (globalMatches.Count > 0)
{
var m = globalMatches[0];
return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent);
}
if (globalMatches.Count > 0) return new PriceResolution(globalMatches[0].Price, globalMatches[0].MinPrice);
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null, null, null) : null;
}
/// <summary>
/// Same resolution `SaleService.ResolveUnitPriceAsync` uses internally, exposed read-only so
/// the frontend can show the real price (region/date-scoped price list, else Item.SalePrice)
/// before a sale is ever submitted, instead of guessing from the flat catalogue price.
/// </summary>
public async Task<PriceResolution?> ResolveByStoreAsync(Guid itemId, Guid storeId, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null) return null;
return await ResolvePriceAsync(itemId, store.Route?.FK_RegionId, item.SalePrice, saleDate);
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null) : null;
}
}
@@ -22,13 +22,10 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
[HttpPost]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
var result = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
@@ -4,11 +4,7 @@ namespace Distribution.Features.Po;
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
public record CreatePoRequest(
Guid SupplierId,
List<PoItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? CreatedByUserId = null);
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
/// <summary>
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
@@ -20,13 +20,10 @@ public class PurchaseReturnController(PurchaseReturnService service, ICurrentUse
[HttpPost("api/purchase-orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId.Value, ct);
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
@@ -2,11 +2,7 @@ namespace Distribution.Features.PurchaseReturn;
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
public record CreatePurchaseReturnRequest(
string Reason,
List<PurchaseReturnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? CreatedByUserId = null);
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
@@ -104,8 +104,6 @@ public class PurchaseReturnService(
FK_ItemId = line.FK_ItemId,
FromLocationId = warehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = line.Quantity,
Type = MovementType.PurchaseReturn,
ReasonCode = request.Reason,
@@ -42,5 +42,5 @@ public interface IRegionRepository
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -129,11 +129,11 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.ShopPOs
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders
join s in context.Stores on o.FK_StoreId equals s.Id
join r in context.Routes on s.FK_RouteId equals r.Id
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
&& o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
select o).ToListAsync();
}
@@ -219,7 +219,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
taxRule?.RuleConfig,
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
}
@@ -3,12 +3,10 @@ namespace Distribution.Features.Returns;
public interface IReturnRepository
{
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
Task<List<Models.SaleReturn>> GetAllAsync();
Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.SaleReturn entity);
void Update(Models.SaleReturn entity);
Task AddCreditNoteAsync(Models.CreditNote entity);
/// <summary>Whether this store has ever had this item on any of its (active) ShopPOs.</summary>
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
}
@@ -1,4 +1,4 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
@@ -6,10 +6,6 @@ namespace Distribution.Features.Returns;
[ApiController]
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/returns/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -17,32 +13,19 @@ public class ReturnController(ReturnService service, ICurrentUserService current
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("api/returns/{id:guid}/reasons")]
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateReasonsAsync(id, request, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/orders/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) =>
Ok(await service.GetByOrderIdAsync(id));
[HttpGet("api/shop-pos/{id:guid}/returns")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/shop-pos/{id:guid}/returns")]
[HttpPost("api/orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId.Value, ct);
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+2 -12
View File
@@ -4,17 +4,7 @@ namespace Distribution.Features.Returns;
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record CreateSaleReturnRequest(
ReturnMode Mode,
List<ReturnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? CreatedByUserId = null);
// The return's financial/stock effect (credit, order total, vehicle stock credit) is applied
// immediately on create with no separate approval step, so quantities/mode can never be edited
// after the fact without desyncing stock and order totals. Only each line's reason code is editable.
public record UpdateReturnReasonItem(Guid ItemId, ReturnReasonCode ReasonCode);
public record UpdateSaleReturnReasonsRequest(List<UpdateReturnReasonItem> Items);
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
public record SaleReturnItemResponse(
Guid Id,
@@ -27,7 +17,7 @@ public record SaleReturnItemResponse(
public record SaleReturnResponse(
Guid Id,
Guid ShopPOId,
Guid OrderId,
ReturnMode Mode,
decimal TotalCreditAmount,
decimal OrderTotalAmountAfter,
@@ -7,37 +7,26 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
{
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<Models.SaleReturn>> GetAllAsync() =>
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId) =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_ShopPOId == shopPOId && r.IsActive)
.Where(r => r.FK_OrderId == orderId && r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.SaleReturn entity) =>
await context.SaleReturns.AddAsync(entity);
public void Update(Models.SaleReturn entity) =>
context.SaleReturns.Update(entity);
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
await context.CreditNotes.AddAsync(entity);
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
await context.ShopPOItems
.Include(oi => oi.ShopPO)
.AnyAsync(oi => oi.ShopPO.FK_StoreId == storeId && oi.ShopPO.IsActive && oi.FK_ItemId == itemId);
await context.OrderItems
.Include(oi => oi.Order)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
}
+129 -22
View File
@@ -1,5 +1,7 @@
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,7 +10,9 @@ namespace Distribution.Features.Returns;
public class ReturnService(
IReturnRepository repo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
@@ -17,7 +21,7 @@ public class ReturnService(
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
private static SaleReturnResponse Map(SaleReturn r) =>
new(r.Id, r.FK_ShopPOId, r.Mode, r.TotalCreditAmount, r.ShopPO.TotalAmount, r.IsActive, r.CreatedAt,
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
@@ -27,36 +31,139 @@ public class ReturnService(
return Map(r);
}
public async Task<List<SaleReturnResponse>> GetAllAsync() =>
(await repo.GetAllAsync()).Select(Map).ToList();
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
/// <summary>
/// Quantities/mode are immutable once created (the stock/order effect already happened) —
/// only each line's reason code can be corrected after the fact.
/// Records a return recognized during the same store visit as an open order and settles it
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
/// A return is only allowed for an item the store has previously ordered.
/// </summary>
public async Task<SaleReturnResponse> UpdateReasonsAsync(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct = default)
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var r = await repo.GetByIdAsync(id);
if (r is null || !r.IsActive)
throw new InvalidOperationException("Return not found.");
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
throw new InvalidOperationException("Cannot record a return against a closed order.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
?? throw new InvalidOperationException("Ride not found.");
var vehicleId = ride.FK_VehicleId;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
var saleReturn = new SaleReturn
{
FK_OrderId = orderId,
Mode = request.Mode,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
var item = r.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive)
?? throw new InvalidOperationException($"Item {line.ItemId} not found on this return.");
item.ReasonCode = line.ReasonCode;
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
? matchingOrderItem.UnitPrice
: item.SalePrice ?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice
});
}
await uow.BeginAsync(ct);
try { repo.Update(r); await uow.CommitAsync(ct); }
try
{
foreach (var line in saleReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = line.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = line.Quantity
});
}
else
{
balance.Quantity += line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = line.Quantity,
Type = MovementType.CustomerReturn,
ReasonCode = line.ReasonCode.ToString(),
ReferenceId = saleReturn.Id,
CreatedBy = createdByUserId
});
}
await repo.AddAsync(saleReturn);
if (request.Mode == ReturnMode.CreditDeduction)
{
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = total;
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
orderRepo.Update(order);
await repo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = total
});
}
else // ItemExchange
{
foreach (var line in saleReturn.Items)
{
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
continue; // no matching line on this order — nothing to offset
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
orderItem.Quantity -= (int)offsetQty;
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
}
orderRepo.Update(order);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(r);
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
}
+13 -4
View File
@@ -1,13 +1,22 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideRepository
{
Task<DeliveryRun?> GetByIdAsync(Guid id);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null);
Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId);
Task AddAsync(DeliveryRun ride);
void Update(DeliveryRun ride);
}
public record OrderSummaryModel(
Guid Id,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
+92 -13
View File
@@ -1,21 +1,23 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
public class RideController(RideService service) : ControllerBase
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status,
[FromQuery] RideType? type,
[FromQuery] Guid? driverProfileId,
[FromQuery] Guid? vehicleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
var result = await service.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return Ok(result);
}
@@ -29,12 +31,8 @@ public class RideController(RideService service) : ControllerBase
[HttpPost]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}/driver")]
@@ -42,13 +40,39 @@ public class RideController(RideService service) : ControllerBase
{
try
{
var result = await service.AssignDriverAsync(id, request.DriverProfileId, ct);
var result = await service.AssignDriverAsync(id, request.FK_DriverProfileId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/open")]
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
{
try
{
var result = await service.OpenAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
try
{
var result = await service.ActivateAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
@@ -61,15 +85,70 @@ public class RideController(RideService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelRideRequest request, CancellationToken ct)
[HttpPost("{id:guid}/cancel-request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CancelAsync(id, request, ct);
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cancel-requests")]
public async Task<IActionResult> GetCancelRequests(
[FromQuery] CancelRequestStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetCancelRequestsAsync(status, page, pageSize);
return Ok(result);
}
[HttpPost("cancel-requests/{id:guid}/approve")]
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cancel-requests/{id:guid}/reject")]
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{id:guid}/cancel-request")]
public async Task<IActionResult> GetCancelRequest(Guid id)
{
var result = await service.GetCancelRequestAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("breakdown-assist/{rideId:guid}")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
{
var result = await service.GetBreakdownAssistAsync(rideId);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
+52 -6
View File
@@ -1,27 +1,73 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Features.Stock;
namespace Distribution.Features.Ride;
// Ride (DeliveryRun)
public record RideResponse(
Guid Id,
RideType RideType,
RideStatus Status,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
Guid SourceWarehouseId,
string SourceWarehouseName,
int Meterstart,
int Meterend,
DateTime? StartedAt,
DateTime? ActivatedAt,
DateTime? ClosedAt,
DateTime CreatedAt);
public record CreateRideRequest(
Guid VehicleId,
Guid DriverProfileId,
int Meterstart);
RideType RideType,
Guid FK_VehicleId,
Guid FK_DriverProfileId,
Guid FK_RouteId,
Guid? FK_SourceWarehouseId);
public record AssignRideDriverRequest(Guid DriverProfileId);
public record AssignRideDriverRequest(Guid FK_DriverProfileId);
public record ActivateRideRequest(int Meterstart);
public record CloseRideRequest(int Meterend);
public record CancelRideRequest(CancelRequestType TypeCode, string Reason);
public record RaiseCancelRequest(CancelRequestType TypeCode, string Reason);
// Cancel Request
public record RideCancelRequestResponse(
Guid Id,
Guid RideId,
CancelRequestType TypeCode,
string Reason,
Guid RequestedBy,
DateTime RequestedAt,
CancelRequestStatus Status,
Guid? ApprovedBy,
DateTime? ApprovedAt);
public record BreakdownAssistRideResponse(
Guid RideId,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
List<StockBalanceResponse> StockLevels);
public record BreakdownAssistOrderResponse(
Guid OrderId,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
public record BreakdownAssistResponse(
Guid RideId,
List<BreakdownAssistRideResponse> ActiveRides,
List<BreakdownAssistOrderResponse> OpenOrders);
+32 -5
View File
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -10,32 +10,59 @@ public class RideRepository(AppDBContext context) : IRideRepository
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.Where(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId) =>
public async Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId) =>
await context.DeliveryRuns
.CountAsync(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId));
public async Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
public async Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
await context.DeliveryRuns.AnyAsync(r =>
r.FK_VehicleId == vehicleId &&
r.Id != excludeRideId &&
r.Status == RideStatus.Open);
(r.Status == RideStatus.Open || r.Status == RideStatus.Active));
public async Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Where(r => r.Id != excludeRideId && r.Status == RideStatus.Active)
.ToListAsync();
public async Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive && o.Store.FK_RouteId == routeId)
.OrderByDescending(o => o.OrderDate)
.Select(o => new OrderSummaryModel(
o.Id,
o.FK_StoreId,
o.Store.StoreName,
o.OrderDate,
o.TotalAmount))
.ToListAsync();
public async Task AddAsync(DeliveryRun ride) =>
await context.DeliveryRuns.AddAsync(ride);
+226 -26
View File
@@ -1,4 +1,4 @@
using Distribution.Features.Stock;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
@@ -10,13 +10,21 @@ public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IRideCancelRequestRepository cancelRepo,
Features.Vehicle.IVehicleRepository vehicleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
public async Task<PagedResponse<RideResponse>> GetAllAsync(
RideStatus? status,
RideType? type,
Guid? driverProfileId,
Guid? vehicleId,
int page,
int pageSize)
{
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -28,18 +36,21 @@ public class RideService(
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
{
if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
throw new InvalidOperationException("Vehicle already has an open ride");
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
?? throw new InvalidOperationException("Vehicle not found");
await CheckDriverComplianceAsync(request.DriverProfileId);
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId;
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
var ride = new DeliveryRun
{
FK_VehicleId = request.VehicleId,
FK_DriverProfileId = request.DriverProfileId,
Status = RideStatus.Open,
Meterstart = request.Meterstart,
StartedAt = DateTime.Now
RideType = request.RideType,
FK_VehicleId = request.FK_VehicleId,
FK_DriverProfileId = request.FK_DriverProfileId,
FK_RouteId = request.FK_RouteId,
FK_SourceWarehouseId = sourceWarehouseId.Value,
Status = RideStatus.Draft
};
await uow.BeginAsync(ct);
@@ -48,19 +59,46 @@ public class RideService(
await rideRepo.AddAsync(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
catch
{
await uow.RollbackAsync(ct);
throw;
}
var saved = await rideRepo.GetByIdAsync(ride.Id);
return ToResponse(saved!);
}
public async Task<RideResponse> OpenAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException($"Cannot open a ride in {ride.Status} status");
if (await rideRepo.HasOpenOrActiveRideForVehicleAsync(ride.FK_VehicleId, ride.Id))
throw new InvalidOperationException("Vehicle already has an open or active ride");
await CheckDriverComplianceAsync(ride.FK_DriverProfileId);
ride.Status = RideStatus.Open;
ride.StartedAt = DateTime.Now;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("Driver can only be changed while the ride is Open");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
var driver = await driverRepo.GetByIdAsync(driverProfileId);
if (driver is null || !driver.IsActive)
@@ -76,16 +114,32 @@ public class RideService(
return ToResponse(saved!);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
public async Task<RideResponse> ActivateAsync(Guid rideId, ActivateRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
throw new InvalidOperationException($"Cannot activate a ride in {ride.Status} status");
if (request.Meterend < ride.Meterstart)
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
@@ -100,21 +154,161 @@ public class RideService(
return ToResponse(ride);
}
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default)
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status");
var existing = await cancelRepo.GetPendingByRideIdAsync(rideId);
if (existing is not null)
throw new InvalidOperationException("There is already a pending cancel request for this ride");
var cancelRequest = new RideCancelRequest
{
FK_RideId = rideId,
TypeCode = request.TypeCode,
Reason = request.Reason,
RequestedBy = requestedBy,
RequestedAt = DateTime.Now,
Status = CancelRequestStatus.Pending
};
ride.Status = RideStatus.CancelRequested;
await uow.BeginAsync(ct);
try
{
await cancelRepo.AddAsync(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<PagedResponse<RideCancelRequestResponse>> GetCancelRequestsAsync(CancelRequestStatus? status, int page, int pageSize)
{
var total = await cancelRepo.GetCountAsync(status);
var items = await cancelRepo.GetAllAsync(status, page, pageSize);
return new PagedResponse<RideCancelRequestResponse>(page, pageSize, total, items.Select(ToCancelResponse).ToList());
}
public async Task<RideCancelRequestResponse> ApproveCancelAsync(Guid cancelRequestId, Guid approvedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
cancelRequest.Status = CancelRequestStatus.Approved;
cancelRequest.ApprovedBy = approvedBy;
cancelRequest.ApprovedAt = DateTime.Now;
ride.Status = RideStatus.Cancelled;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse> RejectCancelAsync(Guid cancelRequestId, Guid rejectedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
cancelRequest.Status = CancelRequestStatus.Rejected;
cancelRequest.ApprovedBy = rejectedBy;
cancelRequest.ApprovedAt = DateTime.Now;
// Restore to previous status: Active if ActivatedAt is set, else Open
ride.Status = ride.ActivatedAt.HasValue ? RideStatus.Active : RideStatus.Open;
await uow.BeginAsync(ct);
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse?> GetCancelRequestAsync(Guid rideId)
{
var cancelRequest = await cancelRepo.GetByRideIdAsync(rideId);
return cancelRequest is null ? null : ToCancelResponse(cancelRequest);
}
public async Task<BreakdownAssistResponse> GetBreakdownAssistAsync(Guid rideId)
{
var strandedRide = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var activeRides = await rideRepo.GetActiveRidesAsync(rideId);
var activeRideResponses = new List<BreakdownAssistRideResponse>();
foreach (var ride in activeRides)
{
var stockLevels = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
activeRideResponses.Add(new BreakdownAssistRideResponse(
ride.Id,
ride.FK_VehicleId,
ride.Vehicle?.NumberPlate ?? string.Empty,
ride.FK_DriverProfileId,
ride.DriverProfile?.User?.FullName ?? string.Empty,
ride.FK_RouteId,
ride.Route?.RouteName ?? string.Empty,
stockLevels.Select(b => new StockBalanceResponse(
b.FK_ItemId,
b.Item.Name,
b.LocationId,
b.LocationType,
b.Quantity,
b.UpdatedAt)).ToList()));
}
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
return new BreakdownAssistResponse(
strandedRide.Id,
activeRideResponses,
openOrders.Select(o => new BreakdownAssistOrderResponse(
o.Id,
o.StoreId,
o.StoreName,
o.OrderDate,
o.TotalAmount)).ToList());
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
@@ -145,9 +339,15 @@ public class RideService(
}
private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status,
new(r.Id, r.RideType, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.FK_RouteId, r.Route?.RouteName ?? string.Empty,
r.FK_SourceWarehouseId, r.SourceWarehouse?.Name ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ClosedAt, r.CreatedAt);
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt);
private static RideCancelRequestResponse ToCancelResponse(RideCancelRequest c) =>
new(c.Id, c.FK_RideId, c.TypeCode, c.Reason, c.RequestedBy, c.RequestedAt,
c.Status, c.ApprovedBy, c.ApprovedAt);
}
@@ -66,12 +66,8 @@ public class RouteController(RouteService service) : ControllerBase
[HttpPost("{id:guid}/visits/check-in")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
try
{
var result = await service.RecordCheckInAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
var result = await service.RecordCheckInAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/visits/check-out")]
+1 -7
View File
@@ -12,13 +12,7 @@ public record AssignStoresToRouteRequest(List<Guid> StoreIds);
public record AssignRouteOwnerRequest(Guid SalesRepId);
public record RecordCheckInRequest(
Guid StoreId,
decimal Latitude,
decimal Longitude,
DateTime? Timestamp,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? CheckedInByUserId = null);
public record RecordCheckInRequest(Guid StoreId, decimal Latitude, decimal Longitude, DateTime? Timestamp);
public record RecordCheckOutRequest(Guid StoreId, DateTime? Timestamp);
+6 -8
View File
@@ -188,14 +188,11 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<RouteVisitResponse?> RecordCheckInAsync(Guid id, RecordCheckInRequest request, CancellationToken ct = default)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user, and skip the route-scope
// check (which also depends on a resolved user) until that's fixed.
var userId = currentUser.UserId ?? request.CheckedInByUserId
?? throw new InvalidOperationException("CheckedInByUserId is required while auth is unavailable");
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var store = await repo.GetStoreByIdAsync(request.StoreId);
if (store is null || !store.IsActive || store.FK_RouteId != id) return null;
@@ -206,7 +203,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
{
FK_RouteId = id,
FK_StoreId = request.StoreId,
FK_UserId = userId,
FK_UserId = currentUser.UserId.Value,
CheckInAt = request.Timestamp ?? DateTime.Now,
CheckInLatitude = request.Latitude,
CheckInLongitude = request.Longitude,
@@ -228,10 +225,11 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<RouteVisitResponse?> RecordCheckOutAsync(Guid id, RecordCheckOutRequest request, CancellationToken ct = default)
{
// TEMPORARY: the route-scope check depends on the broken auth middleware
// (testbug.md #28) — skipped for now, same as check-in.
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var log = await repo.GetOpenVisitAsync(id, request.StoreId);
if (log is null) return null;
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,45 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
await context.Sales
.Where(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
await context.Sales
.CountAsync(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+253
View File
@@ -0,0 +1,253 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
var total = await repo.GetCountAsync(rideId, storeIds);
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
+2 -3
View File
@@ -46,8 +46,7 @@ public record UpdateSchemeRequest(
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales,
bool IsActive);
decimal? BaselineExpectedSales);
public record ApplySchemeRequest(Guid SchemeId);
@@ -99,7 +98,7 @@ public record SchemeEligibilityResponse(
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? ShopPOId,
Guid? OrderId,
decimal? DiscountAmount,
Guid? FreeItemId,
string? FreeItemName,
@@ -20,7 +20,7 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
.Include(s => s.TriggerItem)
.Include(s => s.FreeItem)
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
.AsQueryable();
.Where(s => s.IsActive);
if (regionId.HasValue) query = query.Where(s => s.FK_RegionId == regionId);
@@ -42,15 +42,15 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions
.Include(r => r.FreeItem)
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Where(r => r.FK_SchemeId == schemeId)
.ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
+39 -40
View File
@@ -1,6 +1,6 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Order;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
@@ -10,7 +10,7 @@ namespace Distribution.Features.Scheme;
public class SchemeService(
ISchemeRepository repo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
@@ -27,7 +27,7 @@ public class SchemeService(
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
new(r.Id, r.FK_SchemeId, r.FK_ShopPOId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ───────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ public class SchemeService(
public async Task<SchemeResponse?> GetByIdAsync(Guid id)
{
var scheme = await repo.GetByIdAsync(id);
return scheme is null ? null : ToResponse(scheme);
return scheme is null || !scheme.IsActive ? null : ToResponse(scheme);
}
/// <summary>
@@ -114,7 +114,7 @@ public class SchemeService(
public async Task<SchemeResponse?> UpdateAsync(Guid id, UpdateSchemeRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(id);
if (scheme is null) return null;
if (scheme is null || !scheme.IsActive) return null;
ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig);
if (request.DateRangeFrom > request.DateRangeTo)
@@ -128,7 +128,6 @@ public class SchemeService(
scheme.DateRangeTo = request.DateRangeTo;
scheme.AgentLimitExempt = request.AgentLimitExempt;
scheme.BaselineExpectedSales = request.BaselineExpectedSales;
scheme.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
@@ -243,20 +242,20 @@ public class SchemeService(
return (await GetByIdAsync(schemeId))!;
}
// ── evaluateSchemeEligibility(storeId, shopPOId) ──────────────────────────
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid shopPOId)
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var regionId = store.Route?.FK_RegionId;
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
var candidates = await repo.GetAllAsync();
var result = new List<SchemeEligibilityResponse>();
foreach (var scheme in candidates)
@@ -266,21 +265,21 @@ public class SchemeService(
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, items));
result.Add(EvaluateRule(scheme, orderItems));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
/// not "this ShopPO's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// </summary>
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
{
@@ -305,7 +304,7 @@ public class SchemeService(
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
{
switch (scheme.RuleType)
{
@@ -316,10 +315,10 @@ public class SchemeService(
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no free-goods rule configured.", null, null, null);
var boughtQty = items.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
if (boughtQty < scheme.TriggerQty)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
$"Requires {scheme.TriggerQty} of the trigger item; ShopPO has {boughtQty}.", null, null, null);
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
var multiplier = boughtQty / scheme.TriggerQty.Value;
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
@@ -332,11 +331,11 @@ public class SchemeService(
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
var missing = scheme.BundleComponents
.Where(c => items.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.ToList();
if (missing.Count > 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"ShopPO is missing required bundle components.", null, null, null);
"Order is missing required bundle components.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty);
@@ -346,7 +345,7 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
}
@@ -355,12 +354,12 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
if (tier is null)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"ShopPO total does not meet any slab threshold.", null, null, null);
"Order total does not meet any slab threshold.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
@@ -370,32 +369,32 @@ public class SchemeService(
}
}
// ── applySchemeDiscount(shopPOId, schemeId) ───────────────────────────────
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid shopPOId, Guid schemeId, CancellationToken ct = default)
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, items);
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme.");
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_ShopPOId = shopPOId,
FK_OrderId = orderId,
DiscountAmount = eligibility.DiscountAmount
};
var newItems = new List<ShopPOItem>();
var newOrderItems = new List<OrderItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{
@@ -406,9 +405,9 @@ public class SchemeService(
if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newItems.Add(new ShopPOItem
newOrderItems.Add(new OrderItem
{
FK_ShopPOId = shopPOId,
FK_OrderId = orderId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
@@ -422,7 +421,7 @@ public class SchemeService(
await uow.BeginAsync(ct);
try
{
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
@@ -457,8 +456,8 @@ public class SchemeService(
var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions
.Where(r => r.ShopPO is not null)
.Select(r => r.ShopPO!)
.Where(r => r.Order is not null)
.Select(r => r.Order!)
.Distinct()
.Sum(o => o.TotalAmount);
@@ -1,18 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public interface IShopPORepository
{
Task<Models.ShopPO?> GetByIdAsync(Guid id);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.ShopPO entity);
void Update(Models.ShopPO entity);
void Remove(Models.ShopPO entity);
Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items);
Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems);
}
@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ShopPO;
[ApiController]
[Route("api/shop-pos")]
public class ShopPOController(ShopPOService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] bool? confirmed,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,24 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
public record ShopPOResponse(
Guid Id,
Guid StoreId,
string StoreName,
Guid RaisedById,
Guid? InvoiceId,
decimal TotalAmount,
ShopPOStatus Status,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? FulfilledAt,
List<ShopPOItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,91 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ShopPO;
public class ShopPORepository(AppDBContext context) : IShopPORepository
{
public async Task<Models.ShopPO?> GetByIdAsync(Guid id) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
await context.ShopPOs
.Include(p => p.Store)
.Where(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
await context.ShopPOs
.Include(p => p.Store)
.CountAsync(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.IsActive
&& p.ConfirmedAt != null
&& p.InvoiceId == null
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId))
.ToListAsync();
public async Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId) =>
await context.ShopPOItems
.Include(i => i.Item)
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
public async Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId) =>
await context.ShopPOs
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.FK_StoreId == storeId && p.IsActive)
.ToListAsync();
public async Task AddAsync(Models.ShopPO entity) =>
await context.ShopPOs.AddAsync(entity);
public void Update(Models.ShopPO entity) =>
context.ShopPOs.Update(entity);
public void Remove(Models.ShopPO entity)
{
entity.IsActive = false;
context.ShopPOs.Update(entity);
}
public async Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items) =>
await context.ShopPOItems.AddRangeAsync(items);
public async Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems)
{
var current = await context.ShopPOItems
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
foreach (var item in current)
{
item.IsActive = false;
context.ShopPOItems.Update(item);
}
await context.ShopPOItems.AddRangeAsync(newItems);
}
}
@@ -1,32 +0,0 @@
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public class ShopPOService(
IShopPORepository repo,
IUnitOfWork uow)
{
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt);
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
return po is null ? null : ToResponse(po);
}
}
@@ -15,8 +15,6 @@ public interface IStockRepository
// Movements
Task AddMovementAsync(StockMovement movement);
Task<StockMovement?> GetMovementByIdAsync(Guid id);
void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
}
+2 -16
View File
@@ -85,24 +85,10 @@ public class StockController(StockService service, ICurrentUserService currentUs
[HttpPost("adjustment")]
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.AdjustedByUserId;
if (userId is null) return BadRequest(new { error = "AdjustedByUserId is required while auth is unavailable" });
if (currentUser.UserId is null) return Unauthorized();
try
{
var result = await service.AdjustAsync(request, userId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
}
[HttpPatch("movements/{id}/reason")]
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateMovementReasonAsync(id, request, ct);
var result = await service.AdjustAsync(request, currentUser.UserId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
+3 -12
View File
@@ -12,8 +12,7 @@ public record StockBalanceResponse(
Guid LocationId,
LocationType LocationType,
decimal Quantity,
DateTime UpdatedAt,
string LocationName);
DateTime UpdatedAt);
public record StockTotalResponse(
Guid ItemId,
@@ -35,9 +34,7 @@ public record StockMovementResponse(
string? ReasonCode,
Guid? ReferenceId,
DateTime CreatedAt,
Guid CreatedBy,
string? FromLocationName,
string? ToLocationName);
Guid CreatedBy);
// Write requests
public record TransferRequest(
@@ -67,10 +64,4 @@ public record AdjustmentRequest(
Guid LocationId,
LocationType LocationType,
decimal Quantity,
string ReasonCode,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? AdjustedByUserId = null);
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
public record UpdateMovementReasonRequest(string ReasonCode);
string ReasonCode);
@@ -50,12 +50,6 @@ public class StockRepository(AppDBContext db) : IStockRepository
public async Task AddMovementAsync(StockMovement movement) =>
await db.StockMovements.AddAsync(movement);
public Task<StockMovement?> GetMovementByIdAsync(Guid id) =>
db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id);
public void UpdateMovement(StockMovement movement) =>
db.StockMovements.Update(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
+16 -110
View File
@@ -1,79 +1,19 @@
using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Features.Vehicle;
using Distribution.Features.Warehouse;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Stock;
public class StockService(
IStockRepository repo,
IItemRepository itemRepo,
ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo,
IUnitOfWork uow)
public class StockService(IStockRepository repo, IItemRepository itemRepo, ICategoryRepository categoryRepo, IUnitOfWork uow)
{
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt, locationName);
private static StockBalanceResponse MapBalance(StockBalance b) =>
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt);
// Sentinel id used for the "External" side of a movement (customer, supplier, write-off, adjustment
// source/sink) that has no real Warehouse/Vehicle record — every movement must always show both a
// From and a To, even when one side is outside the tracked system.
public static readonly Guid ExternalLocationId = Guid.Empty;
private static string ExternalLocationLabel(MovementType type, bool isFrom) => type switch
{
MovementType.Sale => "Customer",
MovementType.CustomerReturn => "Customer",
MovementType.PurchaseReceipt => "Supplier",
MovementType.PurchaseReturn => "Supplier",
MovementType.Damage => "Written Off",
MovementType.Adjustment => "Stock Adjustment",
_ => isFrom ? "External Source" : "External Destination",
};
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
private static StockMovementResponse MapMovement(StockMovement m) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName);
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{
var locations = new List<(Guid, LocationType)>();
if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType));
if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType));
var names = await ResolveLocationNamesAsync(locations);
return MapMovement(m, ResolveMovementSideName(m, names, isFrom: true), ResolveMovementSideName(m, names, isFrom: false));
}
private static string? ResolveMovementSideName(StockMovement m, Dictionary<Guid, string> names, bool isFrom)
{
var id = isFrom ? m.FromLocationId : m.ToLocationId;
var type = isFrom ? m.FromLocationType : m.ToLocationType;
if (id is not Guid locId || type is not LocationType locType) return null;
return locType == LocationType.External ? ExternalLocationLabel(m.Type, isFrom) : names.GetValueOrDefault(locId, "—");
}
private async Task<Dictionary<Guid, string>> ResolveLocationNamesAsync(IEnumerable<(Guid Id, LocationType Type)> locations)
{
var warehouseIds = locations.Where(l => l.Type == LocationType.Warehouse).Select(l => l.Id).Distinct().ToList();
var vehicleIds = locations.Where(l => l.Type == LocationType.Vehicle).Select(l => l.Id).Distinct().ToList();
var names = new Dictionary<Guid, string>();
foreach (var id in warehouseIds)
{
var warehouse = await warehouseRepo.GetByIdAsync(id);
names[id] = warehouse?.Name ?? "Unknown Warehouse";
}
foreach (var id in vehicleIds)
{
var vehicle = await vehicleRepo.GetByIdAsync(id);
names[id] = vehicle?.RegNumber ?? "Unknown Vehicle";
}
return names;
}
m.ReferenceId, m.CreatedAt, m.CreatedBy);
// ── Queries ────────────────────────────────────────────────────────────
@@ -82,9 +22,7 @@ public class StockService(
{
var items = await repo.GetBalancesAsync(locationId, locationType, itemId, page, pageSize);
var total = await repo.GetBalancesCountAsync(locationId, locationType, itemId);
var names = await ResolveLocationNamesAsync(items.Select(b => (b.LocationId, b.LocationType)));
return new PagedResponse<StockBalanceResponse>(page, pageSize, total,
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
return new PagedResponse<StockBalanceResponse>(page, pageSize, total, items.Select(MapBalance).ToList());
}
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
@@ -94,9 +32,7 @@ public class StockService(
var balances = await repo.GetBalancesByItemAsync(itemId);
var total = balances.Sum(b => b.Quantity);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return new StockTotalResponse(itemId, item.Name, total,
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
return new StockTotalResponse(itemId, item.Name, total, balances.Select(MapBalance).ToList());
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
@@ -104,20 +40,7 @@ public class StockService(
{
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
var locations = new List<(Guid, LocationType)>();
foreach (var m in items)
{
if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType));
if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType));
}
var names = await ResolveLocationNamesAsync(locations);
return new PagedResponse<StockMovementResponse>(page, pageSize, total,
items.Select(m => MapMovement(
m,
ResolveMovementSideName(m, names, isFrom: true),
ResolveMovementSideName(m, names, isFrom: false))).ToList());
return new PagedResponse<StockMovementResponse>(page, pageSize, total, items.Select(MapMovement).ToList());
}
// ── Writes ─────────────────────────────────────────────────────────────
@@ -148,7 +71,7 @@ public class StockService(
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return MapMovement(movement);
}
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
@@ -160,8 +83,6 @@ public class StockService(
FK_ItemId = request.ItemId,
FromLocationId = request.LocationId,
FromLocationType = request.LocationType,
ToLocationId = ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = request.Quantity,
Type = MovementType.Damage,
ReasonCode = request.ReasonCode,
@@ -177,7 +98,7 @@ public class StockService(
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return MapMovement(movement);
}
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
@@ -194,8 +115,6 @@ public class StockService(
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
@@ -213,7 +132,7 @@ public class StockService(
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return MapMovement(movement);
}
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
@@ -225,10 +144,10 @@ public class StockService(
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
FromLocationId = request.Quantity < 0 ? request.LocationId : null,
FromLocationType = request.Quantity < 0 ? request.LocationType : null,
ToLocationId = request.Quantity > 0 ? request.LocationId : null,
ToLocationType = request.Quantity > 0 ? request.LocationType : null,
Quantity = Math.Abs(request.Quantity),
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
@@ -259,20 +178,7 @@ public class StockService(
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
}
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
public async Task<StockMovementResponse> UpdateMovementReasonAsync(Guid movementId, UpdateMovementReasonRequest request, CancellationToken ct = default)
{
var movement = await repo.GetMovementByIdAsync(movementId)
?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode;
repo.UpdateMovement(movement);
await uow.SaveChangesAsync(ct);
return await MapMovementAsync(movement);
return MapMovement(movement);
}
// ── Helpers ────────────────────────────────────────────────────────────
@@ -1,5 +1,3 @@
using Distribution.Models;
namespace Distribution.Features.Store;
public interface IStoreRepository
@@ -7,10 +5,8 @@ public interface IStoreRepository
Task<Models.Store?> GetByIdAsync(Guid id);
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
Task<List<Models.Store>> GetAllAsync();
Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize);
Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search);
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
}
+5 -18
View File
@@ -1,5 +1,4 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
@@ -8,13 +7,8 @@ namespace Distribution.Features.Store;
public class StoreController(StoreService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? routeId,
[FromQuery] KycStatus? kycStatus,
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(routeId, kycStatus, search, page, pageSize));
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
@@ -48,14 +42,14 @@ public class StoreController(StoreService service) : ControllerBase
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
return result is null ? Forbid() : Ok(result);
}
[HttpPut("{id:guid}/outstanding-balance")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
return result is null ? Forbid() : Ok(result);
}
[HttpGet("{id:guid}/outstanding-balance")]
@@ -106,11 +100,4 @@ public class StoreController(StoreService service) : ControllerBase
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/status")]
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
{
var result = await service.SetActiveStatusAsync(id, request.IsActive, ct);
return result is null ? NotFound() : Ok(result);
}
}
+1 -4
View File
@@ -20,8 +20,7 @@ public record UpdateStoreRequest(
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
bool IsActive = true);
decimal CreditAmount);
public record StoreResponse(
Guid Id,
@@ -56,6 +55,4 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record PatchStoreStatusRequest(bool IsActive);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);

Some files were not shown because too many files have changed in this diff Show More