diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2c48305b7e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/vector-search-go-ci.yml b/.github/workflows/vector-search-go-ci.yml new file mode 100644 index 0000000000..bcb96ae165 --- /dev/null +++ b/.github/workflows/vector-search-go-ci.yml @@ -0,0 +1,75 @@ +# CI for the Azure SQL Vector Search Go quickstart sample. +# Security: uses `pull_request` (read-only token, no secrets on forks) +# with an explicit same-repo check to skip fork PRs entirely. +# +# This CI runs static analysis and unit tests only — no live Azure SQL +# Database or Azure OpenAI resource is required or contacted. Live +# end-to-end validation is a separate, manual step (see the sample's +# README and output/sample-output.txt for the explicit no-live-run +# disclosure). + +name: "Vector Search Go — Build" + +on: + pull_request: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-go/**" + push: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-go/**" + +# Cancel redundant runs for the same PR / branch. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Vet, format-check, build, and test + runs-on: ubuntu-latest + timeout-minutes: 10 + + # Skip CI on fork PRs — prevents external actors from consuming minutes. + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name + + defaults: + run: + working-directory: samples/features/vector-search/vector-search-query-go + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: "1.25" + cache-dependency-path: samples/features/vector-search/vector-search-query-go/go.sum + + - name: Download dependencies + run: go mod download + + - name: Format check (gofmt) + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "The following files are not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Unit tests (no Azure connectivity required) + run: go test ./... -v diff --git a/.github/workflows/vector-search-python-ci.yml b/.github/workflows/vector-search-python-ci.yml new file mode 100644 index 0000000000..4c5443887f --- /dev/null +++ b/.github/workflows/vector-search-python-ci.yml @@ -0,0 +1,74 @@ +# CI for the Azure SQL Vector Search Python quickstart sample. +# Security: uses `pull_request` (read-only token, no secrets on forks) +# with an explicit same-repo check to skip fork PRs entirely. +# +# This CI runs static analysis and unit tests only — no live Azure SQL +# Database or Azure OpenAI resource is required or contacted. Live +# end-to-end validation is a separate, manual step (see the sample's +# README and output/sample-output.txt for the explicit no-live-run +# disclosure). + +name: "Vector Search Python — Build" + +on: + pull_request: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-python/**" + push: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-python/**" + +# Cancel redundant runs for the same PR / branch. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Lint, type-check, and test + runs-on: ubuntu-latest + timeout-minutes: 10 + + # Skip CI on fork PRs — prevents external actors from consuming minutes. + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name + + defaults: + run: + working-directory: samples/features/vector-search/vector-search-query-python + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: "pip" + cache-dependency-path: | + samples/features/vector-search/vector-search-query-python/requirements.txt + samples/features/vector-search/vector-search-query-python/requirements-dev.txt + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint + run: python -m ruff check . + + - name: Format check + run: python -m ruff format --check . + + - name: Type-check + run: python -m mypy src + + - name: Unit tests (no Azure connectivity required) + run: python -m pytest -v diff --git a/.github/workflows/vector-search-typescript-ci.yml b/.github/workflows/vector-search-typescript-ci.yml new file mode 100644 index 0000000000..5e7a8dcd2f --- /dev/null +++ b/.github/workflows/vector-search-typescript-ci.yml @@ -0,0 +1,61 @@ +# CI for the Azure SQL Vector Search TypeScript quickstart sample. +# Security: uses `pull_request` (read-only token, no secrets on forks) +# with an explicit same-repo check to skip fork PRs entirely. + +name: "Vector Search TypeScript — Build" + +on: + pull_request: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-typescript/**" + push: + branches: [master] + paths: + - "samples/features/vector-search/vector-search-query-typescript/**" + +# Cancel redundant runs for the same PR / branch. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build & type-check + runs-on: ubuntu-latest + timeout-minutes: 10 + + # Skip CI on fork PRs — prevents external actors from consuming minutes. + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name + + defaults: + run: + working-directory: samples/features/vector-search/vector-search-query-typescript + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: samples/features/vector-search/vector-search-query-typescript/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Type-check (build) + run: npm run build diff --git a/composer.lock b/composer.lock index 2a25733c91..5ff27f9f38 100644 --- a/composer.lock +++ b/composer.lock @@ -9,16 +9,16 @@ "packages-dev": [ { "name": "squizlabs/php_codesniffer", - "version": "3.5.5", + "version": "3.13.6", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", "shasum": "" }, "require": { @@ -28,18 +28,13 @@ "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" + "bin/phpcbf", + "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -47,26 +42,59 @@ "authors": [ { "name": "Greg Sherwood", - "role": "lead" + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", - "standards" + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } ], - "time": "2020-04-17T01:09:41+00:00" + "time": "2026-08-06T00:17:32+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=7.0.0" }, - "platform-dev": [], - "plugin-api-version": "1.1.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/manage-payg-transition/modify-arc-sql-license-type.ps1 b/manage-payg-transition/modify-arc-sql-license-type.ps1 new file mode 100644 index 0000000000..735403873f --- /dev/null +++ b/manage-payg-transition/modify-arc-sql-license-type.ps1 @@ -0,0 +1,777 @@ + +<# +.SYNOPSIS + Updates the license type for Azure Arc SQL resources to a specified license and license related options. + +.DESCRIPTION + The script updates the license related settings of the SQL extension resources in a specified Entra ID tenant. You can specify a particular subscription, resource group or an individual connected machine. + You can also provide a list of subscriptions as a .CSV file. + By default, all subscriptions in your current tenant id are scanned. + +.VERSION + 3.0.5 - Initial version. + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER MachineName + Optional. A single machine name or a CSV file name containing a list of machine names. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "PAYG", "Paid" or "LicenseOnly" + +.PARAMETER ConsentToRecurringPAYG + Optional. Consents to enabling the recurring PAYG billing. LicenseType must be "PAYG". Applies to CSP subscriptions only. + +.PARAMETER UsePcoreLicense + Optional. Opts in to use unlimited virtualization license if the value is "Yes", or opts out if the value is "No". To opt in, the license type must be "Paid" or "PAYG" + +.PARAMETER EnableESU + Optional. Enables the ESU policy if the value is "Yes" or disables it if the value is "No". To enable, the license type must be "Paid" or "PAYG" + +.PARAMETER Force + Optional. Forces the change of the license type to the specified value on all installed extensions. If not forced, the changes will apply only to the extensions where the license type is undefined. + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +.PARAMETER WaitForCompletion + Optional. If specified, waits for each submitted extension update to reach a terminal + provisioning state and reports the confirmed outcome, instead of returning as soon as the + request is accepted. Extension updates are normally submitted with -NoWait, so by default + the report records "RequestSubmitted", which means the service accepted the request - not + that the Arc agent has applied it. Use this switch when you need confirmed results; + it makes the run substantially slower because each machine is polled individually. + +.PARAMETER WaitTimeoutSeconds + Optional. Maximum number of seconds to wait per resource when -WaitForCompletion is used. + Defaults to 300. Reaching the timeout is not treated as a failure: the outcome is recorded + as "TimedOut" because the update may still be applied by the agent afterwards. + +#> + +param ( + [Parameter (Mandatory=$false)] + [string] $SubId, + + [Parameter (Mandatory= $false)] + [string] $ResourceGroup, + + [Parameter (Mandatory= $false)] + [string] $MachineName, + + [Parameter (Mandatory= $false)] + [ValidateSet("PAYG","Paid","LicenseOnly", IgnoreCase=$false)] + [string] $LicenseType, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $ConsentToRecurringPAYG, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $UsePcoreLicense, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $EnableESU, + + [Parameter (Mandatory= $false)] + [switch] $Force, + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [int] $WaitTimeoutSeconds = 300, + + [Parameter (Mandatory= $false)] + [int] $batchSize = 500, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary +) + +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path ".\modify-arc-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +<# +.SYNOPSIS + Polls an Arc machine extension until its provisioning state is terminal. +.DESCRIPTION + Extension updates are submitted with -NoWait, so the service accepting the request says + nothing about whether the Arc agent applied it. When -WaitForCompletion is used this + polls the extension and reports the confirmed outcome. + + A timeout is deliberately NOT reported as a failure: the agent may still apply the + setting after the script gives up, so the run is recorded as inconclusive rather than + unsuccessful. +#> +function Wait-ArcExtensionProvisioning { + param( + [Parameter(Mandatory = $true)][string]$ResourceGroupName, + [Parameter(Mandatory = $true)][string]$MachineName, + [Parameter(Mandatory = $true)][string]$ExtensionName, + [Parameter(Mandatory = $true)][string]$ExpectedLicenseType, + [Parameter(Mandatory = $true)][int]$TimeoutSeconds + ) + + $terminalStates = @('Succeeded', 'Failed', 'Canceled') + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $delay = 5 + $lastState = 'Unknown' + $mismatch = $null + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds $delay + try { + $current = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName ` + -MachineName $MachineName -Name $ExtensionName -ErrorAction Stop + } catch { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $_.Exception.Message; State = 'Unknown' } + } + + $lastState = "$($current.ProvisioningState)" + + if ($terminalStates -contains $lastState) { + if ($lastState -ne 'Succeeded') { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = "Extension provisioning state is '$lastState'."; State = $lastState } + } + + # A 'Succeeded' provisioning state only means the extension settings were written. + # Confirm the value actually reflects the requested license type. + $applied = $null + if ($null -ne $current.Setting) { + try { $applied = "$($current.Setting['LicenseType'])" } catch { $applied = $null } + } + + if ([string]::IsNullOrEmpty($applied)) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + if ($applied -eq $ExpectedLicenseType) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + + # 'Succeeded' with the wrong license type is ambiguous: the update was submitted + # with -NoWait, so this may still be the *previous* operation's terminal state read + # before the new one started. Keep polling rather than failing on that race; the + # mismatch is only reported if it survives to the deadline. + $mismatch = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." + } + + # Back off gradually to avoid hammering the API on slow agents. + if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } + } + + if ($mismatch) { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $mismatch; State = $lastState } + } + + return [PSCustomObject]@{ + Result = 'TimedOut' + ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." + State = $lastState + } +} + + +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" + "Microsoft.Sql/managedInstances" = "SQL Managed Instances" + "Microsoft.Sql/instancePools" = "SQL Instance Pools" + "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" + "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" + "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" + "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedInvalidState") { + "Extension is not in a valid/Succeeded state." + } elseif ($item.UpdateResult -eq "SkippedNoChangeNeeded") { + "No changes were needed or -Force was not specified to overwrite existing license type." + } else { + "Outcome: $($item.UpdateResult)" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "ResourceType" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter(Mandatory=$false)] + [string] $TenantId = $null, + + [Parameter(Mandatory=$false)] + [switch] $UseManagedIdentity + ) + + # 1) Detect host environment + $envType = 'Local' + if ($env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = 'CloudShell' + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = 'AzureAutomation' + $UseManagedIdentity = $true + } + Write-Output "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context. Use login V1 + Update-AzConfig -LoginExperienceV2 Off + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account) { + if ($TenantId) { + if ($currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already in Az tenant $TenantId" + } + else { + Write-Output "Switching Az context to tenant $TenantId without re-authentication" + $newContext = Set-AzContext -Tenant $TenantId -ErrorAction SilentlyContinue + if($null -eq $newContext -or $newContext.TenantId -ne $TenantId) + { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + } + } + else { + Write-Output "Using existing Az context: Tenant $($currentCtx.Tenant.Id)" + } + } + else { + Write-Output "Not connected to Azure PowerShell. Running Connect-AzAccount..." + if ($UseManagedIdentity) { + if ($TenantId) { + Connect-AzAccount -Identity -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + } + } + else { + if ($TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount | Out-Null + } + } + $ctx = Get-AzContext + Write-Output "Connected to Az PowerShell as: $($ctx.Account) in tenant $($ctx.Tenant.Id)" + } +} + + +# Convert to hashtable explicitly +$tagTable = @{} +if($null -ne $ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } +} +# Ensure connection with both PowerShell and CLI. +if($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + if ($TenantId) { + Connect-Azure -TenantId $TenantId -UseManagedIdentity $UseManagedIdentity + } else { + Connect-Azure -UseManagedIdentity $UseManagedIdentity + } +} else { + if ($TenantId) { + Connect-Azure -TenantId $TenantId + } else { + Connect-Azure + } +} + +$context = Get-AzContext -ErrorAction SilentlyContinue +Write-Output "Connected to Azure as: $($context.Account)" + +if (-not $TenantId) { + $TenantId = $context.Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $TenantId" +} + + +# Ensure the required modules are imported + +try{ + Import-Module Az.Accounts +}catch{ + Write-Output "Can't import module Az.Accounts" +} +try{ + Import-Module Az.ConnectedMachine +} +catch{ + Write-Output "Can't import module Az.ConnectedMachine" +} +try{ + Import-Module Az.ResourceGraph +} +catch{ + Write-Output "Can't import module Az.ResourceGraph" +} + +$modifiedResources = @() + +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Handle MachineName input (single or CSV) +$machineNames = @() +if ($MachineName) { + if ($MachineName -like "*.csv") { + try { + $machines = Import-Csv $MachineName + foreach ($m in $machines) { + if ($m.MachineName) { + $machineNames += $m.MachineName + } + } + Write-Output "Loaded $($machineNames.Count) machine names from CSV." + } catch { + Write-Error "Failed to import machine names from CSV: $_" + exit 1 + } + } else { + $machineNames += $MachineName + } +} + +Write-Host ([Environment]::NewLine + "-- Scanning subscriptions --") + +foreach ($sub in $subscriptions) { + if ($sub.State -ne "Enabled") {continue} + + try { + Set-AzContext -SubscriptionId $sub.Id #Removed TenantID by Sunil + }catch { + write-host "Invalid subscription: $($sub.Id)" + {continue} + } + + Write-Output "Collecting list of resources to update" + + $query = " + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines' + | where properties.detectedProperties.mssqldiscovered == 'true'" + if ($ResourceGroup) { + $query += " + | where resourceGroup =~ '$ResourceGroup'" + } + + if ($machineNames.Count -gt 0) { + $machineFilter = ($machineNames | ForEach-Object { "'$_'" }) -join ", " + $query += "| where name in~ ($machineFilter)" + } + + $query += " + | extend machineId = tolower(tostring(id)) + | project machineId, machineName = tolower(name) + | join kind= inner ( + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines/extensions' + | where properties.publisher =~ 'Microsoft.AzureData' + | where properties.provisioningState == 'Succeeded' + | where properties.settings.LicenseType!='$LicenseType' + | extend extensionName = name + | extend extensionPublisher = properties.publisher + | extend extensionType = properties.type + | parse id with '/subscriptions/' subscriptionId '/resourceGroups/' resourceGroup '/providers/Microsoft.HybridCompute/machines/' machineNameRaw '/extensions/' extensionName + | extend machineName = tolower(machineNameRaw) + ) on `$left.machineName == `$right.machineName + | project machineName, extensionName, resourceGroup, location, subscriptionId, extensionPublisher, extensionType + | order by machineName asc" + + $skipToken = $null + + Write-Output $query + + $allResults = [System.Collections.Generic.List[PSObject]]::new() + do{ + $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken + $allResults.AddRange($resources) + $skipToken = $resources.SkipToken + }while($skipToken) + + Write-Output "Found $($allResults.Count) resource(s) to update" + + + $count = $allResults.Count + + + while($count -gt 0) { + $count-=1 + $setID = @{ + MachineName = $allResults[$count].MachineName + Name = $allResults[$count].extensionName + ResourceGroup = $allResults[$count].resourceGroup + Location = $allResults[$count].location + SubscriptionId = $allResults[$count].subscriptionId + Publisher = $allResults[$count].extensionPublisher + ExtensionType = $allResults[$count].extensionType + } + + write-Output " MachineName - $($setID.MachineName)" + write-Output " ResourceGroup - $($setID.ResourceGroup)" + write-Output " Location - $($setID.Location)" + write-Output " SubscriptionId - $($setID.SubscriptionId)" + write-Output " ExtensionType - $($setID.ExtensionType)" + + # Get connected machine info + $sqlvm = Get-AzConnectedMachine -Name $setID.MachineName -ResourceGroup $setID.ResourceGroup | Select-Object Name, Tags, Status + + + $excludedByTags = $false + foreach ($tag in $tagTable.Keys){ + if($sqlvm.Tags.ContainsKey($tag)) + { + if($sqlvm.Tags[$tag] -eq $tagTable[$tag]){ + $excludedByTags=$true + $value = $tagTable[$tag] + write-Output "Exclusion tag $($tag):$value. Skipping..." + Break; + } + } + } + if($excludedByTags){ + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = "Unknown" + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "SkippedTags" + UpdateError = "Matched exclusion tag $($tag):$value" + } + $modifiedResources += $resourceRecord + } else { + + + $WriteSettings = $false + $ext = Get-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -MachineName $setID.MachineName + + # Collect data before modification. UpdateResult/UpdateError are populated + # after the actual Set-AzConnectedMachineExtension call below (or left as + # "NotAttempted" if the resource was skipped) so the CSV/console output + # reflects what actually happened, not just what was intended. + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = $ext.Setting["LicenseType"] + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "NotAttempted" + UpdateError = "" + # Cores + } + $modifiedResources += $resourceRecord + + if($ext.ProvisioningState -ne "Succeeded") { + write-Output "Extension is not in a valid state. Skipping..." + $resourceRecord.UpdateResult = "SkippedInvalidState" + $resourceRecord.UpdateError = "Extension provisioning state is '$($ext.ProvisioningState)' (expected 'Succeeded')" + continue + } else { + $LO_Allowed = (!$ext.Setting["enableExtendedSecurityUpdates"] -and !$EnableESU) -or ($EnableESU -eq "No") + + if ($LicenseType) { + if (($LicenseType -eq "LicenseOnly") -and !$LO_Allowed) { + write-Output "ESU must be disabled before license type can be set to $($LicenseType)" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = "ESU must be disabled before license type can be set to $LicenseType" + } else { + if ($ext.Setting["LicenseType"]) { + if ($Force) { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + elseif ("$($ext.Setting['LicenseType'])" -ne $LicenseType) { + # The machine already carries a license type and -Force was not + # supplied, so it is deliberately left alone. Say so explicitly: + # other settings may still be written below, and without this the + # run would report "Updated" for a license type that never changed. + Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + $resourceRecord.UpdateResult = "SkippedNoForce" + $resourceRecord.UpdateError = "Machine carries LicenseType '$($ext.Setting['LicenseType'])'. Re-run with -Force to overwrite." + } + } else { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + } + } + + if ($EnableESU) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($EnableESU -eq "No")) { + $ext.Setting["enableExtendedSecurityUpdates"] = ($EnableESU -eq "Yes") + $ext.Setting["esuLastUpdatedTimestamp"] = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + if ($UsePcoreLicense) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($UsePcoreLicense -eq "No")) { + $ext.Setting["UsePhysicalCoreLicense"] = @{ + "IsApplied" = ($UsePcoreLicense -eq "Yes"); + "LastUpdatedTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + # Add or update ConsentToRecurringPAYG setting if applicable + if ($ConsentToRecurringPAYG -eq "Yes") { + $isPayg = ($LicenseType -eq "PAYG") -or ($ext.Setting["LicenseType"] -eq "PAYG") + if ($isPayg) { + if (-not $ext.Setting.ContainsKey("ConsentToRecurringPAYG") -or -not $ext.Setting["ConsentToRecurringPAYG"]["Consented"]) { + $ext.Setting["ConsentToRecurringPAYG"] = @{ + "Consented" = $true; + "ConsentTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $WriteSettings = $true + } + } + } + + write-Output " Write Settings - $($WriteSettings)" + + if (-not $ReportOnly) { + If ($WriteSettings) { + try { + $settings = @{} + foreach ($h in $ext.Setting.Keys) { + $settings[$h]=$($ext.Setting[$h]) + } + # -ErrorAction Stop is required here: Set-AzConnectedMachineExtension + # can emit a non-terminating error (e.g. "An extension of type ... is + # still processing. Only one instance of an extension may be in + # progress at a time...") which, combined with -NoWait, would otherwise + # be printed to the console and then fall through to the "Updated" + # success message below without ever entering the catch block. + Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop + Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" + $resourceRecord.UpdateResult = "RequestSubmitted" + + if ($WaitForCompletion) { + Write-Output " Waiting for the extension update on [$($setID.MachineName)] to complete (timeout ${WaitTimeoutSeconds}s)..." + $wait = Wait-ArcExtensionProvisioning -ResourceGroupName $setID.ResourceGroup ` + -MachineName $setID.MachineName -ExtensionName $setID.Name ` + -ExpectedLicenseType "$($settings['LicenseType'])" -TimeoutSeconds $WaitTimeoutSeconds + + $resourceRecord.UpdateResult = $wait.Result + $resourceRecord.UpdateError = $wait.ErrorMessage + + switch ($wait.Result) { + 'Succeeded' { Write-Output " Confirmed -- [$($setID.MachineName)] provisioning state '$($wait.State)'." } + 'TimedOut' { Write-Warning "Timed out waiting for [$($setID.MachineName)]: $($wait.ErrorMessage)" } + default { Write-Warning "The extension update for [$($setID.MachineName)] did not succeed: $($wait.ErrorMessage)" } + } + } + } catch { + $errorMessage = $_.Exception.Message + Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = $errorMessage + continue + } + } elseif ($resourceRecord.UpdateResult -eq "NotAttempted") { + $resourceRecord.UpdateResult = "SkippedNoChangeNeeded" + $resourceRecord.UpdateError = "No configuration changes were required." + } + } else { + Write-Output "ReportOnly mode enabled. Skipping modification for: $($setID.MachineName)" + $resourceRecord.UpdateResult = "ReportOnly" + } + } + + } + } +} + +# --- Final Report --- +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime + +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($executionDuration.ToString())" + +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_arc.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +write-Output "Arc SQL Update Script completed" + +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} diff --git a/manage-payg-transition/modify-azure-sql-license-type.ps1 b/manage-payg-transition/modify-azure-sql-license-type.ps1 new file mode 100644 index 0000000000..6c61b8826f --- /dev/null +++ b/manage-payg-transition/modify-azure-sql-license-type.ps1 @@ -0,0 +1,1297 @@ +<# +.SYNOPSIS + Updates the license type for Azure SQL resources (SQL DBs, Elastic Pools, Managed Instances, Instance Pools, SQL VMs) + to a specified model ("LicenseIncluded" or "BasePrice"). + +.DESCRIPTION + The script updates Azure SQL License types across subscriptions by modifying the license settings for a variety of SQL resources. It supports processing resources in one of the following ways: + The script processes several types of Azure SQL resources including: + + SQL Virtual Machines (SQL VMs) + SQL Managed Instances + SQL Databases + Elastic Pools + SQL Instance Pools + DataFactory SSIS Integration Runtimes + +.VERSION + 1.0.0 - Initial version. + 1.0.2 - Modified to fix errors and to remove the auto-start of the offline resources. + 1.0.3 - Added transcript. + 1.0.4 - Fixed RG filter for SQL DB + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "LicenseIncluded" (default) or "BasePrice". + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +.PARAMETER ResourceName + Optional. If specified, only updates resources related to this name: + - For SQL Server: Updates all databases under the specified server + - For SQL Managed Instance: Updates the specified instance + - For SQL VM: Updates the specified VM + +.PARAMETER WaitForCompletion + Optional. If specified, waits for each update to reach a terminal state before continuing + and reports the confirmed outcome ("Updated"). By default the script submits updates with + --no-wait and reports "RequestSubmitted", meaning the service accepted the request rather + than that the change has been applied. + + Note: Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option, so SSIS + integration runtimes always wait regardless of this switch and always report "Updated". + SQL virtual machines are submitted asynchronously through a direct ARM request because + 'az sql vm update' has no --no-wait option; see Invoke-SqlVmLicenseUpdate. +#> + +param ( + [Parameter(Mandatory = $false)] + [string] $SubId, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroup, + + [Parameter(Mandatory = $false)] + [ValidateSet("LicenseIncluded", "BasePrice", IgnoreCase = $false)] + [string] $LicenseType = "LicenseIncluded", + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [string] $ResourceName, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary +) + + +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +# Suppress unnecessary logging output +$VerbosePreference = "SilentlyContinue" +$DebugPreference = "SilentlyContinue" +$ProgressPreference = "SilentlyContinue" +$InformationPreference = "SilentlyContinue" +$WarningPreference = "SilentlyContinue" + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter (Mandatory= $true)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch]$UseManagedIdentity + ) + + # 1) Detect environment + $envType = "Local" + if ($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = "CloudShell" + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = "AzureAutomation" + $UseManagedIdentity=$true + } + Write-Verbose "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context - reuse an existing, already-authenticated context for the + # requested tenant instead of forcing a fresh interactive/managed-identity login every run. + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and $currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $TenantId). Reusing existing context." + } + else { + Write-Output "Not connected to Azure PowerShell for tenant $TenantId. Running Connect-AzAccount..." + if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + $ctx = Connect-AzAccount -Tenant $TenantId -Identity -ErrorAction Stop + } + else { + $ctx = Connect-AzAccount -Tenant $TenantId -ErrorAction Stop + } + Write-Output "Connected to Azure PowerShell as: $($ctx.Context.Account)" + } + + # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. + if (Get-Command az -ErrorAction SilentlyContinue) { + $acct = az account show --output json 2>$null | ConvertFrom-Json + if ($acct -and $acct.tenantId -eq $TenantId) { + Write-Output "Azure CLI already logged in as: $($acct.user.name) (tenant $TenantId). Reusing existing session." + } + else { + Write-Output "Running az login..." + if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + az login --tenant $TenantId --identity | Out-Null + } + else { + az login --tenant $TenantId | Out-Null + } + $acct = az account show --output json | ConvertFrom-Json + } + Write-Output "Azure CLI logged in as: $($acct.user.name)" + } +} + +<# +.SYNOPSIS + Runs an 'az ... update' command and reports whether it actually succeeded. +.DESCRIPTION + The Azure CLI signals failure through its exit code, not through a thrown + exception, so piping its output straight into ConvertFrom-Json silently + swallows errors and makes a failed update indistinguishable from a + successful one. This wrapper checks $LASTEXITCODE and returns a result + object used to populate the UpdateResult/UpdateError columns of the report. + + By default updates are submitted with --no-wait so a large estate is not + processed serially; the caller then records "RequestSubmitted" rather than + "Updated", because the service has only accepted the request at that point. + Passing -WaitForCompletion to the script omits --no-wait, making the CLI poll + the operation to a terminal state so the outcome is confirmed. +.PARAMETER SupportsNoWait + Set for commands that accept --no-wait. 'az sql vm update' does not; SQL VMs are + submitted asynchronously through Invoke-SqlVmLicenseUpdate instead. +#> +function Invoke-AzCliLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description, + [switch]$SupportsNoWait + ) + + $effectiveArgs = @($Arguments) + $submittedOnly = $false + if ($SupportsNoWait -and -not $WaitForCompletion) { + $effectiveArgs += '--no-wait' + $submittedOnly = $true + } + + $output = & az @effectiveArgs 2>&1 + + if ($LASTEXITCODE -ne 0) { + $message = ($output | Out-String).Trim() + Write-Warning "Failed to update $Description`: $message" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; Submitted = $submittedOnly } + } + + # --no-wait produces no output, so only attempt to parse when something came back. + $parsed = $null + $raw = ($output | Out-String).Trim() + if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { $parsed = $raw | ConvertFrom-Json } catch { $parsed = $raw } + } + + # Note: this function must not write to the success stream. Anything emitted there + # would be merged into the return value, turning it into an array and hiding the + # message from the caller. Callers log their own success line. + return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $submittedOnly } +} + + +<# +.SYNOPSIS + Runs a read-only Azure CLI query and reports failures instead of silently returning nothing. +.DESCRIPTION + Discovery calls used to be piped straight into ConvertFrom-Json. The Azure CLI signals + failure through $LASTEXITCODE rather than by throwing, so a failed query produced $null, + which every caller then treated as "no resources found". A transient error therefore looked + exactly like an empty result and the affected resources were skipped without any indication + that they had not actually been examined. + + This wrapper checks the exit code, surfaces the real service error as a warning, and returns + the parsed value normalised to an array so callers can use .Count safely. +#> +function Invoke-AzCliQuery { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -ne 0) { + $message = ($output | Out-String).Trim() + Write-Warning "Unable to query $Description`: $message" + return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $message } + } + + $raw = ($output | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($raw)) { + return [PSCustomObject]@{ Success = $true; Value = @(); ErrorMessage = "" } + } + + try { $parsed = $raw | ConvertFrom-Json } + catch { + Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + } + + # Normalise to an array so .Count is meaningful for both single objects and empty results. + return [PSCustomObject]@{ Success = $true; Value = @($parsed); ErrorMessage = "" } +} + + +<# +.SYNOPSIS + Updates the license type of a SQL virtual machine, asynchronously by default. +.DESCRIPTION + 'az sql vm update' has no --no-wait option and blocks until the operation reaches a + terminal state, which for a SQL VM is typically around two minutes per resource. + Update-AzSqlVM advertises -NoWait and -AsJob but both are broken in + Az.SqlVirtualMachine 2.4.0 (-NoWait forwards the bound parameter into Get-AzSqlVM, + which rejects it; -AsJob throws a NullReferenceException). + + To honour the script's async-by-default contract this function talks to ARM directly: + it reads the resource, changes only sqlServerLicenseType and writes it back. ARM + accepts the request and returns an Azure-AsyncOperation header without waiting for the + provisioning to finish, so the call returns in seconds instead of minutes. + + When -WaitForCompletion is passed, or if the ARM round trip fails for any reason, the + original synchronous 'az sql vm update' path is used so behaviour degrades safely. +#> +function Invoke-SqlVmLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string]$ResourceId, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ResourceGroup, + [Parameter(Mandatory = $true)][string]$LicenseType + ) + + $cliArguments = @('sql','vm','update','-n',$Name,'-g',$ResourceGroup,'--license-type',$LicenseType,'-o','json') + + if ($WaitForCompletion) { + return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + } + + $apiVersion = '2023-10-01' + $path = "$ResourceId`?api-version=$apiVersion" + + try { + $get = Invoke-AzRestMethod -Path $path -Method GET -ErrorAction Stop + if ($get.StatusCode -ne 200) { + throw "GET returned HTTP $($get.StatusCode): $($get.Content)" + } + + # Read-modify-write: the payload is the body ARM just returned with a single + # property changed, so no unrelated settings are dropped by the PUT. + $resource = $get.Content | ConvertFrom-Json + $resource.properties.sqlServerLicenseType = $LicenseType + + $put = Invoke-AzRestMethod -Path $path -Method PUT -Payload ($resource | ConvertTo-Json -Depth 30) -ErrorAction Stop + if ($put.StatusCode -ge 400) { + throw "PUT returned HTTP $($put.StatusCode): $($put.Content)" + } + + $parsed = $null + if (-not [string]::IsNullOrWhiteSpace($put.Content)) { + try { $parsed = $put.Content | ConvertFrom-Json } catch { $parsed = $put.Content } + } + + return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $true } + } + catch { + Write-Warning "Asynchronous update of SQL VM '$Name' failed ($($_.Exception.Message)). Falling back to the synchronous 'az sql vm update' path." + return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + } +} + + +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" + "Microsoft.Sql/managedInstances" = "SQL Managed Instances" + "Microsoft.Sql/instancePools" = "SQL Instance Pools" + "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" + "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" + "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" + "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedNotRunning") { + "Underlying VM is deallocated / stopped. Azure requires the VM to be running to update license type." + } elseif ($item.UpdateResult -eq "SkippedDR") { + "Resource has Disaster Recovery (DR) license configured." + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedNotStopped") { + "Integration Runtime is not in stopped state." + } else { + "Unknown reason ($($item.UpdateResult))" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "ResourceType" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + +$finalStatus = @() + +# Convert to hashtable explicitly +$tagTable = @{} +if($ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } +} + +if (-not $TenantId) { + $TenantId = (Get-AzContext).Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $TenantId" +} + +# Ensure connection with both PowerShell and CLI. Use V1 login. +Update-AzConfig -LoginExperienceV2 Off +if ($UseManagedIdentity) { + Connect-Azure ($TenantId, $UseManagedIdentity) +}else{ + Connect-Azure ($TenantId) +} + +# Ensure the required modules are imported + +# Ensure NuGet provider is available +if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force +} + +# Check if the required Az.Accounts module (at the minimum version this script needs) is already +# available. Checking Get-InstalledModule -Name "Az" only detects the "Az" meta-package and false +# -positives as "not found" when the individual Az.* modules were installed some other way (e.g. +# preinstalled on the machine, installed individually, or via a package manager). That mismatch +# triggered an unnecessary "Install-Module -Name Az -Force", which fails/hangs when the modules are +# already loaded/in use. Instead, check directly for the module/version this script actually needs. +$requiredAzAccountsVersion = [version]"4.2.0" +$azAccountsAvailable = Get-Module -ListAvailable -Name Az.Accounts | + Where-Object { $_.Version -ge $requiredAzAccountsVersion } | + Sort-Object Version -Descending | + Select-Object -First 1 + +if (-not $azAccountsAvailable) { + Write-Output "Az.Accounts module (>= $requiredAzAccountsVersion) not found. Installing latest version..." + Install-Module -Name Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Scope CurrentUser -Repository PSGallery -Force +} else { + Write-Output "Az.Accounts module $($azAccountsAvailable.Version) already satisfies the minimum required version ($requiredAzAccountsVersion). No action needed." +} + +# Import Az.Accounts with minimum version requirement +try { + Import-Module Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Force + Write-Output "Az.Accounts module imported successfully." +} catch { + Write-Error "Failed to import Az.Accounts: $_" + return +} + +# Ensure Az.DataFactory is available and import it +try { + if (-not (Get-Module -ListAvailable -Name Az.DataFactory)) { + Write-Output "Az.DataFactory module not found. Installing..." + Install-Module -Name Az.DataFactory -Scope CurrentUser -Force + } else { + Write-Output "Az.DataFactory module is already installed." + } + Import-Module Az.DataFactory -Force +} catch { + Write-Error "Can't import module Az.DataFactory: $_" +} + +# Map License Types for SQL VMs: LicenseIncluded -> PAYG, BasePrice -> AHUB. +$SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "AHUB" } + +# Modified resources array +$modifiedResources = @() + +# Determine the subscriptions to process: CSV file, single subscription, or all accessible subscriptions. +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Build resource group filter if specified. +$rgFilter = if ($ResourceGroup) { "resourceGroup=='$ResourceGroup'" } else { "" } +$scriptStartTime = Get-Date +Write-Output "Our adventure begins at: $scriptStartTime`n" +$tagsFilter = $null +if($tagTable.Keys.Count -gt 0) { + $tagsFilter += " && " + $tagcount = $tagTable.Keys.Count + foreach ($tag in $tagTable.Keys) { + $tagcount-- + $tagsFilter += " tags.$($tag) != '$($tagTable[$tag])' " + if($tagcount -gt 0) { + $tagsFilter += " && " + } + } +} + +# Process each subscription. +foreach ($sub in $subscriptions) { + try { + Write-Output "===== Entering Subscription: $($sub.name) =====" + Write-Output "Switching context to subscription: $($sub.name)" + <#if($SqlVmLicenseType -eq "LicenseIncluded") { + Write-Output "SQL VM License Type: PAYG" + $ArcSQLServerExtensionDeployment = az tag list --resource-id "/subscriptions/$sub.id" --query "properties.tags.ArcSQLServerExtensionDeployment" -o json | ConvertFrom-Json + if ($ArcSQLServerExtensionDeployment -ne "LicenseIncluded") { + Write-Output "SQL VM License Type: PAYG" + az tag update --resource-id /"/subscriptions/$sub.id" --operation merge --tags ArcSQLServerExtensionDeployment=PAYG | Out-Null + } + } else { + Write-Output "SQL VM License Type: AHUB" + }#> + + Write-Output "License Type: $LicenseType" + az account set --subscription $sub.id + if ($LASTEXITCODE -ne 0) { + # Every az call below is scoped by the CLI's active subscription. If the switch + # fails they would all silently run against whichever subscription was previously + # selected, so resources in the wrong subscription could be updated. + Write-Warning "Skipping subscription '$($sub.name)' ($($sub.id)): the Azure CLI context could not be switched to it." + continue + } + + # --- Section: Update SQL Virtual Machines --- + try { + Write-Output "Seeking SQL Virtual Machines that require a license update to $SqlVmLicenseType..." + + # Build SQL VM query + $sqlVmQuery = "[?sqlServerLicenseType!='${SqlVmLicenseType}' && sqlServerLicenseType!='DR'" + + # Add resource group filter if specified + if ($rgFilter) { + $sqlVmQuery += " && $rgFilter" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $sqlVmQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $sqlVmQuery += " $tagsFilter" + } + + $sqlVmQuery += "].{name:name, resourceGroup:resourceGroup, sqlServerLicenseType:sqlServerLicenseType, type:type, id:id, Location:location}" + + Write-Output "Seeking SQL Virtual Machines with filter $sqlVmQuery..." + $sqlVmQueryResult = Invoke-AzCliQuery -Description "SQL virtual machines" -Arguments @('sql','vm','list','--query',$sqlVmQuery,'-o','json') + if (-not $sqlVmQueryResult.Success) { + Write-Warning "SQL virtual machines could not be listed, so none were assessed in this subscription. Re-run to retry." + } + $sqlVMs = $sqlVmQueryResult.Value + $sqlVmsToUpdate = [System.Collections.ArrayList]::new() + if($sqlVMs.Count -eq 0) { + Write-Output "No SQL VMs found that require a license update." + } else { + Write-Output "Found $($sqlVMs.Count) SQL VMs that require a license update." + } + foreach ($sqlvm in $sqlVMs) { + + if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) + { + $vmStatusQuery = Invoke-AzCliQuery -Description "power state of VM '$($sqlvm.name)'" -Arguments @( + 'vm','get-instance-view','--resource-group',$sqlvm.resourceGroup,'--name',$sqlvm.name, + '--query',"{Name:name, ResourceGroup:resourceGroup, PowerState:instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus | [0]}",'-o','json') + if (-not $vmStatusQuery.Success) { + # Without a power state the VM would silently fail the "VM running" test + # below and be skipped as though it were switched off. + Write-Warning "Skipping SQL VM '$($sqlvm.name)': its power state could not be read, so it was not assessed. Re-run to retry." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "UnknownPowerState" + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "Failed" + UpdateError = "Power state could not be read" + } + continue + } + $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 + if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { + + $vmResult = "NotAttempted" + $vmError = "" + + if ($ReportOnly) { + $vmResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' (would change '$($sqlvm.sqlServerLicenseType)' -> '$SqlVmLicenseType')." + } else { + Write-Output "Updating SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' to license type '$SqlVmLicenseType'..." + $update = Invoke-SqlVmLicenseUpdate -ResourceId $sqlvm.id -Name $sqlvm.name -ResourceGroup $sqlvm.resourceGroup -LicenseType $SqlVmLicenseType + if ($update.Success) { + $finalStatus += $update.Result + $vmResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL VM '$($sqlvm.name)': $vmResult (license type '$SqlVmLicenseType')" + } + else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = $vmStatus.PowerState + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = $vmResult + UpdateError = $vmError + # Cores + } + } + elseif ($vmStatus.PowerState -ne "VM running") { + Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' is in '$($vmStatus.PowerState)' state (not running). Skipping update..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = $vmStatus.PowerState + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedNotRunning" + UpdateError = "Underlying VM is in '$($vmStatus.PowerState)' state (must be running to update license)" + } + } + elseif ($sqlvm.sqlServerLicenseType -eq "DR") { + Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' has license type 'DR'. Skipping update..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = $vmStatus.PowerState + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedDR" + UpdateError = "SQL VM has Disaster Recovery ('DR') license type" + } + } + } + else { + Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" + } + } + } + if($sqlVmsToUpdate.Count -eq 0) { + Write-Output "No stopped SQL VMs needed to be started for a license update." + } else { + Write-Output "Found $($sqlVmsToUpdate.Count) to Start SQL VMs that require a license update." + } + } + catch { + Write-Error "An error occurred while updating SQL VMs: $_" + } + + # --- Section: Update SQL Managed Instances (Stopped then Ready) " + $sqlMIsToUpdate = [System.Collections.ArrayList]::new() + try { + + + # Build Managed Instance query + $miRunningQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" + + # Add resource group filter if specified + if ($rgFilter) { + $miRunningQuery += " && $rgFilter" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $miRunningQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $miRunningQuery += " $tagsFilter" + } + + $miRunningQuery += "].{name:name, state:state, resourceGroup:resourceGroup, licenseType:licenseType, location:location, id:id, ResourceType:type}" + + Write-Output "Processing SQL Managed Instances that are running with filter $miRunningQuery..." + $miQueryResult = Invoke-AzCliQuery -Description "SQL Managed Instances" -Arguments @('sql','mi','list','--query',$miRunningQuery,'-o','json') + if (-not $miQueryResult.Success) { + Write-Warning "SQL Managed Instances could not be listed, so none were assessed in this subscription. Re-run to retry." + } + $runningMIs = $miQueryResult.Value + if($runningMIs.Count -eq 0) { + Write-Output "No SQL Managed Instances found that require a license update." + } else { + Write-Output "Found $($runningMIs.Count) SQL Managed Instances that require a license update." + } + foreach ($mi in $runningMIs) { + + $miResult = "NotAttempted" + $miError = "" + + if ($ReportOnly) { + $miResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' (would change '$($mi.licenseType)' -> '$LicenseType')." + } else { + Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." + $update = Invoke-AzCliLicenseUpdate -Description "SQL Managed Instance '$($mi.name)'" -SupportsNoWait -Arguments @( + 'sql','mi','update','--name',$mi.name,'--resource-group',$mi.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { + $finalStatus += $update.Result + $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + } + else { $miResult = "Failed"; $miError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($mi.id -split '/')[2] + ResourceName = $mi.name + ResourceType = $mi.ResourceType + Status = $mi.state + OriginalLicenseType = $mi.licenseType + ResourceGroup = $mi.resourceGroup + Location = $mi.location + UpdateResult = $miResult + UpdateError = $miError + } + } + } + catch { + Write-Error "An error occurred while updating SQL Managed Instances: $_" + } + + # --- Section: Update SQL Databases and Elastic Pools --- + + try { + Write-Output "Querying SQL Servers within this subscription..." + + # First, let's verify we're in the right subscription context + $currentSubContext = az account show --query id -o tsv + Write-Output "Currently in subscription context: $currentSubContext" + + if ($currentSubContext -ne $sub.id) { + Write-Output "Subscription context mismatch! Re-setting context..." + az account set --subscription $sub.id + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not re-select subscription '$($sub.id)'; skipping SQL Server, database and elastic pool processing to avoid querying the wrong subscription." + throw "Subscription context could not be set to '$($sub.id)'." + } + } + + # Build SQL Server query with proper JMESPath syntax + $serverQuery = "" + $filterAdded = $false + + # Start with an empty filter array + if ($rgFilter -or $ResourceName -or $tagsFilter) { + $serverQuery = "[" + + # Add resource group filter if specified + if ($rgFilter) { + $serverQuery += "?$rgFilter" + $filterAdded = $true + } + + # Add name filter if ResourceName is provided + if ($ResourceName) { + if ($filterAdded) { + $serverQuery += " && name=='$ResourceName'" + } else { + $serverQuery += "?name=='$ResourceName'" + $filterAdded = $true + } + } + + # Add tag filter if specified + if ($tagsFilter -and $filterAdded) { + $serverQuery += "$tagsFilter" + } elseif ($tagsFilter) { + $serverQuery += "?type=='Microsoft.Sql/servers'$tagsFilter" # A trick to make the tags filter work when it's the only filter + } + + $serverQuery += "]" + } else { + # No filters, get all servers + $serverQuery = "[]" + } + + # Output the query for debugging + Write-Output "SQL Server query: $serverQuery" + + # Get all servers first as a fallback in case the query fails + $allServersQuery = Invoke-AzCliQuery -Description "SQL Servers in the subscription" -Arguments @('sql','server','list','-o','json') + $allServers = $allServersQuery.Value + Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" + + # Now try the filtered query + $serversQuery = Invoke-AzCliQuery -Description "SQL Servers matching the specified filters" -Arguments @('sql','server','list','--query',"$serverQuery",'-o','json') + if (-not $serversQuery.Success) { + # Distinguish a failed lookup from a genuinely empty one: falling through here + # would print "No SQL Servers found" and skip every database and elastic pool + # in the subscription as though there were nothing to do. + Write-Warning "SQL Servers could not be listed, so no databases or elastic pools were assessed in this subscription. Re-run to retry." + $servers = @() + } else { + $servers = $serversQuery.Value + } + + # Verify if we got any results + if ($null -eq $servers -or $servers.Count -eq 0) { + Write-Output "WARNING: No SQL Servers found with the specified filters." + Write-Output "Available SQL Servers in subscription:" + $allServers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + + # Only fall back to scanning every server in the subscription when the + # caller did not restrict the scope. Falling back while -ResourceGroup + # (or -ResourceName) was supplied would silently widen the blast radius + # far beyond what was asked for: the elastic pool query below is not + # resource-group filtered, so pools on out-of-scope servers would be + # modified. + if (-not $ResourceName -and -not $ResourceGroup) { + Write-Output "Proceeding with all SQL Servers since no specific ResourceName or ResourceGroup was provided." + $servers = $allServers + } else { + Write-Output "Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing." + $servers = @() + } + } else { + Write-Output "Found $($servers.Count) SQL Servers matching the criteria." + $servers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + } + + # Process each server + foreach ($server in $servers) { + # Update SQL Databases + Write-Output "Scanning SQL Databases on server '$($server.name)' in resource group '$($server.resourceGroup)'..." + + # First get all databases to check if any exist + $allDbsQuery = Invoke-AzCliQuery -Description "databases on server '$($server.name)'" -Arguments @( + 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'-o','json') + if (-not $allDbsQuery.Success) { + Write-Warning "Skipping server '$($server.name)': its databases could not be listed, so they cannot be assessed. Re-run to retry." + continue + } + $allDbs = $allDbsQuery.Value + Write-Output "Found a total of $($allDbs.Count) databases on server '$($server.name)'" + + # Build database query with better error handling + $dbQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" + + # Add tags filter if specified + if ($tagsFilter) { + $dbQuery += "$tagsFilter" + } + if ($rgFilter) { + $dbQuery += " && $rgFilter" + } + + $dbQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" + + Write-Output "Database query: $dbQuery" + + # Get databases with error handling + try { + $dbsQuery = Invoke-AzCliQuery -Description "databases requiring an update on server '$($server.name)'" -Arguments @( + 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$dbQuery",'-o','json') + if (-not $dbsQuery.Success) { + Write-Warning "Skipping server '$($server.name)': its databases could not be assessed for a license update. Re-run to retry." + continue + } + $dbs = $dbsQuery.Value + + if ($null -eq $dbs) { + Write-Output "No SQL Databases found on Server $($server.name) that require a license update." + } elseif ($dbs.Count -eq 0) { + Write-Output "No SQL Databases found on Server $($server.name) that require a license update." + } else { + Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.name) that require a license update:" + $dbs | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($db in $dbs) { + + $dbResult = "NotAttempted" + $dbError = "" + + if ($ReportOnly) { + $dbResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Database '$($db.name)' on server '$($server.name)' (would change '$($db.licenseType)' -> '$LicenseType')." + } else { + Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." + $update = Invoke-AzCliLicenseUpdate -Description "SQL Database '$($db.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( + 'sql','db','update','--name',$db.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'-o','json') + if ($update.Success) { + $finalStatus += $update.Result + $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + } + else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($db.id -split '/')[2] + ResourceName = $db.name + ResourceType = $db.ResourceType + Status = $db.State + OriginalLicenseType = $db.licenseType + ResourceGroup = $db.resourceGroup + Location = $db.location + UpdateResult = $dbResult + UpdateError = $dbError + } + } + } + } catch { + Write-Output "Error querying databases on server '$($server.name)': $_" + } + + # Update Elastic Pools with similar improved error handling + try { + Write-Output "Scanning Elastic Pools on server '$($server.name)'..." + + # First check if there are any elastic pools + $allPoolsQuery = Invoke-AzCliQuery -Description "elastic pools on server '$($server.name)'" -Arguments @( + 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--only-show-errors','-o','json') + if (-not $allPoolsQuery.Success) { + Write-Warning "Elastic pools on server '$($server.name)' could not be listed and were not assessed. Re-run to retry." + $allPools = @() + } else { + $allPools = $allPoolsQuery.Value + } + + if ($null -eq $allPools -or $allPools.Count -eq 0) { + Write-Output "No Elastic Pools found on server '$($server.name)'." + } else { + Write-Output "Found $($allPools.Count) total Elastic Pools on server '$($server.name)'." + + # Build elastic pool query with better formatting + $elasticPoolQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" + + # Add tags filter if specified + if ($tagsFilter) { + $elasticPoolQuery += " $tagsFilter" + } + + $elasticPoolQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:state}" + + Write-Output "Elastic Pool query: $elasticPoolQuery" + + $elasticPoolsQueryResult = Invoke-AzCliQuery -Description "elastic pools requiring an update on server '$($server.name)'" -Arguments @( + 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$elasticPoolQuery",'--only-show-errors','-o','json') + if (-not $elasticPoolsQueryResult.Success) { + Write-Warning "Elastic pools on server '$($server.name)' could not be assessed for a license update. Re-run to retry." + } + $elasticPools = $elasticPoolsQueryResult.Value + + if ($null -eq $elasticPools -or $elasticPools.Count -eq 0) { + Write-Output "No Elastic Pools found on Server $($server.name) that require a license update." + } else { + Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.name) that require a license update:" + $elasticPools | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($pool in $elasticPools) { + + $poolResult = "NotAttempted" + $poolError = "" + + if ($ReportOnly) { + $poolResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for Elastic Pool '$($pool.name)' on server '$($server.name)' (would change '$($pool.licenseType)' -> '$LicenseType')." + } else { + Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." + $update = Invoke-AzCliLicenseUpdate -Description "Elastic Pool '$($pool.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( + 'sql','elastic-pool','update','--name',$pool.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'--only-show-errors','-o','json') + if ($update.Success) { + $finalStatus += $update.Result + $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + } + else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($pool.id -split '/')[2] + ResourceName = $pool.name + ResourceType = $pool.ResourceType + Status = $pool.State + OriginalLicenseType = $pool.licenseType + ResourceGroup = $pool.resourceGroup + Location = $pool.location + UpdateResult = $poolResult + UpdateError = $poolError + } + } + } + } + } catch { + Write-Output "Error processing Elastic Pools on server '$($server.name)': $_" + } + } + } catch { + Write-Output "An error occurred while processing SQL Databases or Elastic Pools: $_" + } + + # --- Section: Update SQL Instance Pools --- + try { + Write-Output "Searching for SQL Instance Pools that require a license update..." + + # Build instance pool query (skip the passive replicas) + $instancePoolsQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" + + # Add resource group filter if specified + if ($rgFilter) { + $instancePoolsQuery += " && $rgFilter" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $instancePoolsQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $instancePoolsQuery += " $tagsFilter" + } + + $instancePoolsQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" + + $instancePoolsQueryResult = Invoke-AzCliQuery -Description "SQL instance pools" -Arguments @('sql','instance-pool','list','--query',$instancePoolsQuery,'-o','json') + if (-not $instancePoolsQueryResult.Success) { + Write-Warning "SQL instance pools could not be listed, so none were assessed in this subscription. Re-run to retry." + } + $instancePools = $instancePoolsQueryResult.Value + $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } + if($poolsToUpdate.Count -eq 0) { + Write-Output "No SQL Instance Pools found that require a license update." + } else { + Write-Output "Found $($poolsToUpdate.Count) SQL Instance Pools that require a license update." + } + foreach ($pool in $poolsToUpdate) { + + $ipResult = "NotAttempted" + $ipError = "" + + if ($ReportOnly) { + $ipResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' (would change '$($pool.licenseType)' -> '$LicenseType')." + } else { + Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." + $update = Invoke-AzCliLicenseUpdate -Description "SQL Instance Pool '$($pool.name)'" -SupportsNoWait -Arguments @( + 'sql','instance-pool','update','--name',$pool.name,'--resource-group',$pool.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { + $finalStatus += $update.Result + $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + } + else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($pool.id -split '/')[2] + ResourceName = $pool.name + ResourceType = $pool.ResourceType + Status = $pool.State + OriginalLicenseType = $pool.licenseType + ResourceGroup = $pool.resourceGroup + Location = $pool.location + UpdateResult = $ipResult + UpdateError = $ipError + } + } + } + catch { + Write-Error "An error occurred while updating SQL Instance Pools: $_" + } + + # --- Section: Update DataFactory SSIS Integration Runtimes --- + try { + Write-Output "Processing DataFactory SSIS Integration Runtime resources..." + Set-AzContext -Subscription $sub.id | Out-Null + Get-AzDataFactoryV2 | + Where-Object { + $_.ProvisioningState -eq "Succeeded" -and + ([string]::IsNullOrEmpty($ResourceGroup) -or $_.ResourceGroupName -eq $ResourceGroup) + } | + ForEach-Object { + $df = $_ + $IRs = Get-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName | + Where-Object { + $_.Type -eq "Managed" -and + $_.State -ne "Starting" -and + # Only SSIS integration runtimes carry a LicenseType. The default + # 'AutoResolveIntegrationRuntime' is also Type 'Managed' but has a null + # LicenseType; without this check it passes the filter below (since + # $null -ne $LicenseType) and the update fails with + # 'DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork'. + (-not [string]::IsNullOrEmpty($_.LicenseType)) -and + $_.LicenseType -ne $LicenseType -and + ([string]::IsNullOrEmpty($ResourceName) -or $_.Name -eq $ResourceName) + } + + if ($null -eq $IRs -or @($IRs).Count -eq 0) { + Write-Output "No SSIS integration runtimes found on DataFactory '$($df.DataFactoryName)' that require a license update." + } else { + $IRs | ForEach-Object { + $ir = $_ + $irResult = "NotAttempted" + $irError = "" + + if ($ReportOnly) { + $irResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' (would change '$($ir.LicenseType)' -> '$LicenseType')." + } else { + if (-not [string]::IsNullOrEmpty($ResourceName) -and $ir.State -ne "Stopped") { + Write-Output "ADF Integration Service '$($ir.Name)' is not in stopped state" + $irResult = "SkippedNotStopped" + $irError = "Integration runtime is not in stopped state (must be stopped to update license)" + } else { + Write-Output "Updating DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' to license type $LicenseType..." + try { + $result = Set-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -Name $ir.Name -LicenseType $LicenseType -Force -ErrorAction Stop + $finalStatus += $result + $irResult = "Updated" + Write-Output "-- DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' updated to license type $LicenseType" + } + catch { + $irResult = "Failed" + $irError = $_.Exception.Message + Write-Warning "Failed to update integration runtime '$($ir.Name)' on DataFactory '$($df.DataFactoryName)': $irError" + } + } + } + + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($ir.Id -split '/')[2] + ResourceName = $ir.Name + ResourceType = "Microsoft.DataFactory/factories/integrationRuntimes" + Status = $ir.State + OriginalLicenseType = $ir.LicenseType + ResourceGroup = $df.ResourceGroupName + Location = $df.Location + UpdateResult = $irResult + UpdateError = $irError + } + } + } + } + } + catch { + Write-Error "An error occurred while updating DataFactory SSIS Integration Runtimes: $_" + } + + } + catch { + Write-Error "An error occurred while processing subscription '$($sub.name)': $_" + } +} + +$scriptEndTime = Get-Date +$totalDuration = $scriptEndTime - $scriptStartTime + +# --- Final Report --- +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($totalDuration.ToString())" + +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_azure.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + # Export-Csv derives its header from the first object only, so rows built by + # different sections (some of which carry UpdateResult/UpdateError) are projected + # onto one consistent schema to avoid silently dropping columns. + $csvColumns = @('TenantID','SubID','ResourceName','ResourceType','Status', + 'OriginalLicenseType','ResourceGroup','Location','UpdateResult','UpdateError') + $modifiedResources | + Select-Object -Property $csvColumns | + Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +Write-Output "Azure SQL Update Script completed" + +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} diff --git a/runnow.ps1 b/runnow.ps1 new file mode 100644 index 0000000000..2740f7681e --- /dev/null +++ b/runnow.ps1 @@ -0,0 +1,16 @@ +.\manage-payg-transition\modify-arc-sql-license-type.ps1 ` +-UsePcoreLicense 'No' ` +-ReportOnly ` +-TenantId '72f988bf-86f1-41af-91ab-2d7cd011db47' ` +-NoSummary ` +-LicenseType 'PAYG' ` +-SubId 'a5082b19-8a6e-4bc5-8fdd-8ef39dfebc39' ` +-ResourceGroup 'rajpoArcEUSUSP' ` +-Force +.\manage-payg-transition\modify-azure-sql-license-type.ps1 ` +-LicenseType 'LicenseIncluded' ` +-ResourceGroup 'rajpoArcEUSUSP' ` +-SubId 'a5082b19-8a6e-4bc5-8fdd-8ef39dfebc39' ` +-TenantId '72f988bf-86f1-41af-91ab-2d7cd011db47' ` +-NoSummary ` +-ReportOnly diff --git a/samples/applications/azure-sql-mcp/.gitignore b/samples/applications/azure-sql-mcp/.gitignore new file mode 100644 index 0000000000..6aab378106 --- /dev/null +++ b/samples/applications/azure-sql-mcp/.gitignore @@ -0,0 +1,3 @@ +.azure/ +dab-config.generated.json +infra/main.json diff --git a/samples/applications/azure-sql-mcp/README.md b/samples/applications/azure-sql-mcp/README.md new file mode 100644 index 0000000000..0bd73c5b58 --- /dev/null +++ b/samples/applications/azure-sql-mcp/README.md @@ -0,0 +1,212 @@ +![](../../../media/solutions-microsoft-logo-small.png) + +# Hosted MCP in Connector Namespace — Azure SQL Database + +This sample deploys a hosted Model Context Protocol (MCP) server in [Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp), backed by Azure SQL Database. It uses [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/) (`azd`) and Bicep to provision the infrastructure, seed a sample `dbo.BlogPosts` table through a post-provision SQL script, and configure managed identity access. After deployment, MCP clients such as GitHub Copilot in Visual Studio Code can query the database through the hosted MCP server. + + + +## Before you begin + +To run this sample, you need the following prerequisites. + +**Software prerequisites:** + +1. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (`az`) +1. [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) (`azd`) +1. PowerShell 7+ on Windows, or Bash on Linux/macOS +1. [Visual Studio Code](https://code.visualstudio.com/) with the [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) extension (to connect to the deployed MCP server) + +**Azure prerequisites:** + +1. An Azure subscription with permissions to create resource groups and resources. +1. Permission to create Azure SQL Database, Application Insights, Log Analytics workspace, and Connector Namespace resources. +1. Permission to create an Azure SQL Database Microsoft Entra ID administrator for the signed-in user. + + + +## Run this sample + +From this folder: + +```bash +azd auth login +azd init +azd up +``` + +> **Cross-platform:** `azd up` works on Windows (runs the PowerShell post-provision hook), macOS, and Linux (runs the Bash post-provision hook). No additional tools are required beyond the prerequisites listed above. A standalone `deploy.ps1` script is also included as an alternative for PowerShell users. + +#### `azd init` prompts + +When you run `azd init` for the first time, it detects the existing `azure.yaml` and Bicep templates: + +1. **"How do you want to initialize your app?"** — Select **Use code in the current directory**. +2. **"Confirm and continue initializing this app"** — Press **Enter** to confirm the detected services. +3. **"Enter a new environment name"** — Pick any name, for example `mcp-dev`. This name is used as a prefix for Azure resource names. + +You only need to run `azd init` once. Subsequent deployments only require `azd up`. + +#### `azd up` prompts + +When you run `azd up`, you are prompted for: + +- **Azure Subscription:** Select the Azure subscription to deploy to. +- **Azure location:** Choose a supported region (e.g. `eastasia`, `westcentralus`). +- **`deployerLoginName` infrastructure parameter:** Enter your Azure sign-in email or user principal name, for example `user@contoso.com`. If you don't know the value, run the following command in a different terminal instance and use the result: + + ```bash + az account show --query user.name -o tsv + ``` +- **`connectorNamespaceIdentityType` infrastructure parameter:** Enter `SystemAssigned` for the default Connector Namespace managed identity, or `UserAssigned` to create and attach a user-assigned managed identity. + +The `deployerLoginName` value is used to create the Azure SQL Database server with Microsoft Entra ID-only authentication and set you as the SQL admin. + +### Optional: use a user-assigned managed identity + +When `azd up` prompts for `connectorNamespaceIdentityType`, enter `UserAssigned` to test with a user-assigned managed identity. + +When set to `UserAssigned`, the template creates a user-assigned managed identity, attaches it to the Connector Namespace, passes its client ID to the hosted MCP server, and grants that identity access to Azure SQL Database. + +Choose the identity type before the first deployment. Connector Namespace doesn't allow changing attached user-assigned identities after the namespace is created. To switch between `SystemAssigned` and `UserAssigned`, create a new azd environment or run `azd down --purge` and deploy again. + +For more information about managed identity options in Azure Connector Namespace, see [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp). + +### Connect from Visual Studio Code + +After `azd up` completes, the MCP endpoint URL is printed. Add it to VS Code using the UI. For full details, see the [VS Code MCP servers documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). + +1. In VS Code, open the Command Palette: + - Windows/Linux: Ctrl+Shift+P + - macOS: Cmd+Shift+P +1. Run **MCP: Add Server**. +1. Choose **HTTP** as the server type. +1. Paste the MCP endpoint URL printed by `azd up`. +1. Enter a server name, for example `sql-mcp`. +1. Choose whether to save the server in user settings or workspace settings. +1. Start the `sql-mcp` server when VS Code prompts you. + +VS Code prompts you to sign in with Microsoft. + +### Try it out + +Once the MCP server is connected in VS Code, open Copilot Chat (Agent mode) and try prompts like: + +- *"List the blog posts in the database."* +- *"What tables are available?"* +- *"Show me the blog post from the .NET Blog."* + +Copilot uses the MCP tools (`describe_entities`, `read_records`) exposed by Data API builder to query Azure SQL Database and return results. + + + +## Sample details + +### Architecture + +```mermaid +graph TB + Client["VS Code / Copilot / MCP Client"] + Client -->|"MCP (HTTP + SSE)"| CN + subgraph CN["Connector Namespace"] + MCP["Hosted MCP Server\n(Data API builder)"] + end + MCP -->|"Managed Identity"| SQL[("Azure SQL Database")] +``` + +### Resources deployed + +| Resource | Purpose | +|----------|---------| +| **Resource Group** | Logical container for all deployed resources | +| **Azure SQL Database logical server** | Microsoft Entra ID-only authentication, with you as the server admin | +| **Azure SQL Database** | Basic SKU database with a seeded `dbo.BlogPosts` sample table used by the MCP server | +| **SQL Firewall Rules** | Allows Azure services/resources, Azure Portal Query Editor, and your public IP for setup | +| **Log Analytics Workspace** | Stores Application Insights telemetry | +| **Application Insights** | Collects telemetry from the hosted MCP server | +| **Connector Namespace** | Hosts MCP servers with system-assigned managed identity by default, or user-assigned managed identity when configured | +| **Hosted MCP server** | Data API builder MCP server configuration deployed to the Connector Namespace | +| **MCP Access Policy** | Grants you access to invoke MCP tools | + +Resource names use the pattern `--` where possible, for example `sql-mcp-dev-a1b2c3d4`. The suffix is deterministic for the subscription, environment name, and location so names are readable and stable across redeployments. + +### What `azd up` does + +| Step | Action | +|------|--------| +| **Provision** | Deploys Azure SQL Database, SQL firewall rules, Log Analytics, Application Insights, Connector Namespace, hosted MCP server, and MCP access policy. | +| **Post-provision** | Allows your public IP through the SQL firewall, creates and seeds `dbo.BlogPosts`, creates the Connector Namespace managed identity SQL user, grants SQL permissions, generates `dab-config.generated.json`, and prints the MCP endpoint plus Azure Portal resource group link. | + +The hosted MCP server receives: + +- the included `dab-config.json` (the Data API builder configuration file) as `properties.hostedMcpServer.configuration.configFile` +- the generated connection string as `SQL_CONNECTION_STRING` +- the Application Insights connection string as `APPLICATIONINSIGHTS_CONNECTION_STRING` +- `AZURE_CLIENT_ID` when the sample is configured to use a user-assigned managed identity + +No database or Application Insights connection strings are checked in. + +This sample includes a ready-to-use `dab-config.json`, the Data API builder configuration file that defines which database entities are exposed through MCP tools. If you want to create or customize a Data API builder configuration from scratch, install the DAB CLI and use it to generate a config file. For more information, see [Install the Data API builder CLI](https://learn.microsoft.com/azure/data-api-builder/command-line/install). + +For details about the hosted MCP server resource model and supported server types, see [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp). For a walkthrough focused on the Azure SQL Database hosted MCP server, see [Hosted MCP server quickstart for SQL](https://learn.microsoft.com/azure/logic-apps/connector-namespace/hosted-mcp-quickstart?pivots=sql). + +### Inspect resources in Azure Portal + +After deployment, the post-provision output includes a link to the Azure resource group in the Azure Portal. Use that page to inspect the Azure SQL Database logical server, Application Insights resource, Log Analytics workspace, Connector Namespace, and hosted MCP server. + +To allow additional users to connect to the MCP server: + +1. Open the deployed **Connector Namespace** resource in the Azure Portal. +1. Open the hosted MCP server configuration, for example `sql-mcp`. +1. Add an access policy for each additional user or group that should be allowed to invoke the MCP server. + +### Sample data + +The post-provision hook creates and seeds `dbo.BlogPosts` with these entries: + +| Title | Source | +|-------|--------| +| Hosted MCP servers in Azure Connector Namespace | Microsoft Learn | +| Durable Workflows in Microsoft Agent Framework | .NET Blog | + +### SQL firewall access + +The deployment configures two SQL firewall paths: + +| Rule | When | Purpose | +|------|------|---------| +| `AllowAzureServices` | During Bicep provisioning | Allows Azure services/resources, including Azure Portal Query Editor, to reach the SQL server. | +| `AllowDeployerIp` | During post-provision | Detects your current public IP and allows your local machine to seed and query the database. | + +If SQL reports a different blocked client IP during post-provision, the script adds that IP and retries. + + + +## Clean up + +Using azd: + +```bash +azd down --purge +``` + +Or with Azure CLI: + +```bash +# Replace with your azd environment name. +az group delete --name rg- --yes --no-wait + +# Optional: remove the subscription-scope deployment record. +az deployment sub delete --name +``` + + + +## Related links + +- [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp) +- [Hosted MCP server quickstart for Azure SQL Database](https://learn.microsoft.com/azure/logic-apps/connector-namespace/hosted-mcp-quickstart?pivots=sql) +- [Azure SQL Database MCP server support in Data API builder](https://learn.microsoft.com/azure/data-api-builder/mcp/overview) +- [Install the Data API builder CLI](https://learn.microsoft.com/azure/data-api-builder/command-line/install) +- [Connector Namespace overview](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-overview) +- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/) diff --git a/samples/applications/azure-sql-mcp/azure.yaml b/samples/applications/azure-sql-mcp/azure.yaml new file mode 100644 index 0000000000..75f2d5be47 --- /dev/null +++ b/samples/applications/azure-sql-mcp/azure.yaml @@ -0,0 +1,15 @@ +name: azure-sql-mcp +metadata: + template: azure-sql-mcp +hooks: + postprovision: + windows: + shell: pwsh + run: ./scripts/post-provision.ps1 + interactive: true + continueOnError: false + posix: + shell: sh + run: ./scripts/post-provision.sh + interactive: true + continueOnError: false diff --git a/samples/applications/azure-sql-mcp/dab-config.json b/samples/applications/azure-sql-mcp/dab-config.json new file mode 100644 index 0000000000..58fd870adf --- /dev/null +++ b/samples/applications/azure-sql-mcp/dab-config.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.7.90/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@env('SQL_CONNECTION_STRING')", + "options": { + "set-session-context": false + } + }, + "runtime": { + "rest": { + "enabled": false, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": false, + "path": "/graphql", + "allow-introspection": true + }, + "mcp": { + "enabled": true, + "path": "/mcp" + }, + "host": { + "cors": { + "origins": [], + "allow-credentials": false + }, + "authentication": { + "provider": "AppService" + }, + "mode": "development" + } + }, + "entities": { + "BlogPosts": { + "source": { + "object": "dbo.BlogPosts", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "BlogPosts", + "plural": "BlogPosts" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/samples/applications/azure-sql-mcp/deploy.ps1 b/samples/applications/azure-sql-mcp/deploy.ps1 new file mode 100644 index 0000000000..9269551e6c --- /dev/null +++ b/samples/applications/azure-sql-mcp/deploy.ps1 @@ -0,0 +1,277 @@ +<# +.SYNOPSIS + Deploy a Connector Namespace with a hosted Azure SQL Database MCP server. + +.DESCRIPTION + Standalone PowerShell deployment script (alternative to azd). Provisions + Azure SQL Database, Connector Namespace, hosted MCP server, seeds the + database, grants managed identity access, and prints the MCP endpoint URL + ready for VS Code. + + Note: This script requires PowerShell 7+. For cross-platform deployment + without PowerShell, use 'azd up' instead — it runs the Bash post-provision + script on macOS/Linux automatically. + +.PARAMETER DabConfigPath + Path to the DAB configuration file. Defaults to dab-config.json in the project root. + +.PARAMETER EnvironmentName + Name for the deployment environment. Used to generate unique resource names. + +.PARAMETER Location + Azure region for SQL and general resources. Default: eastasia. + +.PARAMETER ConnectorNamespaceLocation + Azure region for the Connector Namespace. Must be a preview region. + Default: eastasia. + +.PARAMETER DatabaseName + Name of the SQL database. Default: mcpdb. + +.PARAMETER ConnectorNamespaceIdentityType + Managed identity type for the Connector Namespace. Default: SystemAssigned. + Use UserAssigned to create and attach a user-assigned managed identity. + +.EXAMPLE + .\deploy.ps1 -EnvironmentName mcp-dev + # Uses ./dab-config.json, deploys to eastasia + +.EXAMPLE + .\deploy.ps1 -EnvironmentName mcp-dev -DabConfigPath .\my-custom-dab.json -Location eastasia +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$EnvironmentName, + + [string]$DabConfigPath, + + [string]$Location = 'eastasia', + + [string]$ConnectorNamespaceLocation = 'eastasia', + + [string]$DatabaseName = 'mcpdb', + + [ValidateSet('SystemAssigned', 'UserAssigned')] + [string]$ConnectorNamespaceIdentityType = 'SystemAssigned' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ProjectRoot = $PSScriptRoot + +# Resolve DAB config path +if (-not $DabConfigPath) { + $DabConfigPath = Join-Path $ProjectRoot 'dab-config.json' +} +if (-not (Test-Path $DabConfigPath)) { + Write-Error "DAB config not found at: $DabConfigPath. Provide -DabConfigPath or place dab-config.json in the project root." + exit 1 +} +$DabConfigPath = Resolve-Path $DabConfigPath +Write-Host "Using DAB config: $DabConfigPath" -ForegroundColor Cyan + +# ── Step 1: Detect deployer identity ────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 1/5: Detecting deployer identity" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$deployerLogin = az account show --query user.name -o tsv +if (-not $deployerLogin) { + Write-Error "Not logged in. Run 'az login' first." + exit 1 +} +Write-Host " Deployer: $deployerLogin" -ForegroundColor Green + +$deployerObjectId = az ad signed-in-user show --query id -o tsv +Write-Host " Object ID: $deployerObjectId" -ForegroundColor Green + +$deployerIp = try { (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 10) } catch { '' } +if ($deployerIp) { + Write-Host " Public IP: $deployerIp" -ForegroundColor Green +} else { + Write-Host " Public IP: (could not detect — SQL firewall rule skipped)" -ForegroundColor Yellow +} + +# ── Step 2: Deploy Bicep ────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 2/5: Deploying infrastructure (Bicep)" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$bicepFile = Join-Path $ProjectRoot 'infra' 'main.bicep' +if (-not (Test-Path $bicepFile)) { + Write-Error "Bicep file not found at: $bicepFile" + exit 1 +} + +# Copy DAB config to expected location for loadTextContent('../dab-config.json') +$expectedDabPath = Join-Path $ProjectRoot 'dab-config.json' +if ($DabConfigPath -ne (Resolve-Path $expectedDabPath -ErrorAction SilentlyContinue)) { + Write-Host " Copying DAB config to project root for Bicep..." -ForegroundColor Yellow + Copy-Item -Path $DabConfigPath -Destination $expectedDabPath -Force +} + +Write-Host " Deploying to subscription..." -ForegroundColor Yellow + +$deployment = az deployment sub create ` + --name "mcp-deploy-$EnvironmentName" ` + --location $Location ` + --template-file $bicepFile ` + --parameters environmentName=$EnvironmentName ` + location=$Location ` + connectorNamespaceLocation=$ConnectorNamespaceLocation ` + deployerLoginName=$deployerLogin ` + deployerPrincipalId=$deployerObjectId ` + deployerIpAddress=$deployerIp ` + databaseName=$DatabaseName ` + connectorNamespaceIdentityType=$ConnectorNamespaceIdentityType ` + --query "properties.outputs" ` + -o json 2>&1 + +if ($LASTEXITCODE -ne 0) { + Write-Host $deployment -ForegroundColor Red + Write-Error "Bicep deployment failed." + exit 1 +} + +$outputs = $deployment | ConvertFrom-Json + +$sqlServerFqdn = $outputs.SQL_SERVER_FQDN.value +$sqlDbName = $outputs.SQL_DATABASE_NAME.value +$connectorNsName = $outputs.CONNECTOR_NAMESPACE_NAME.value +$connectorNsSami = $outputs.CONNECTOR_NAMESPACE_PRINCIPAL_ID.value +$sqlIdentityName = $outputs.SQL_IDENTITY_NAME.value +$sqlIdentityPrincipal = $outputs.SQL_IDENTITY_PRINCIPAL_ID.value +$mcpEndpointUrl = $outputs.MCP_ENDPOINT_URL.value +$rgName = $outputs.RESOURCE_GROUP_NAME.value + +Write-Host " Deployment succeeded!" -ForegroundColor Green +Write-Host " Resource Group: $rgName" +Write-Host " SQL Server: $sqlServerFqdn" +Write-Host " Connector Namespace: $connectorNsName" +Write-Host " SQL MI User: $sqlIdentityName" +Write-Host " SQL MI Principal ID: $sqlIdentityPrincipal" +Write-Host " MCP Endpoint: $mcpEndpointUrl" + +# ── Step 3: Seed the database ───────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 3/5: Seeding the database" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$token = az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv + +$seedSql = @" +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END +PRINT 'BlogPosts table seeded.'; +"@ + +$sqlSuccess = $false +try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $sqlDbName -AccessToken $token -Query $seedSql + Write-Host " Database seeded." -ForegroundColor Green + $sqlSuccess = $true +} catch { + Write-Host " Invoke-Sqlcmd unavailable, trying sqlcmd CLI..." -ForegroundColor Yellow + try { + sqlcmd -S $sqlServerFqdn -d $sqlDbName -Q $seedSql --authentication-method=ActiveDirectoryDefault + Write-Host " Database seeded." -ForegroundColor Green + $sqlSuccess = $true + } catch { + Write-Host " Could not seed automatically." -ForegroundColor Red + } +} + +# ── Step 4: Grant managed identity access ───────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 4/5: Granting managed identity database access" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$grantSql = @" +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$sqlIdentityName') +BEGIN + CREATE USER [$sqlIdentityName] FROM EXTERNAL PROVIDER; +END +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$sqlIdentityName'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [$sqlIdentityName]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$sqlIdentityName'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [$sqlIdentityName]; +GRANT VIEW DEFINITION TO [$sqlIdentityName]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$sqlIdentityName') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$sqlIdentityName'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$sqlIdentityName'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1; +PRINT 'Managed identity access granted.'; +"@ + +if ($sqlSuccess) { + try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $sqlDbName -AccessToken $token -Query $grantSql + Write-Host " Managed identity access granted." -ForegroundColor Green + } catch { + try { + sqlcmd -S $sqlServerFqdn -d $sqlDbName -Q $grantSql --authentication-method=ActiveDirectoryDefault + Write-Host " Managed identity access granted." -ForegroundColor Green + } catch { + $sqlSuccess = $false + } + } +} + +if (-not $sqlSuccess) { + Write-Host "" + Write-Host " Run these SQL commands manually in Azure Portal Query Editor:" -ForegroundColor Yellow + Write-Host " (SQL Server → $sqlServerFqdn → Database → $sqlDbName → Query editor)" -ForegroundColor Yellow + Write-Host "" + Write-Host $seedSql -ForegroundColor White + Write-Host "" + Write-Host $grantSql -ForegroundColor White + Write-Host "" +} + +# ── Step 5: Done ────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 5/5: Deployment Complete!" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" +Write-Host " MCP Endpoint: $mcpEndpointUrl" -ForegroundColor Green +$subscriptionId = az account show --query id -o tsv +$resourceGroupUrl = "https://portal.azure.com/#@/resource/subscriptions/$subscriptionId/resourceGroups/$rgName/overview" +Write-Host " Azure Portal: $resourceGroupUrl" -ForegroundColor Green +Write-Host "" +Write-Host " Use the MCP endpoint with any MCP client that supports HTTP transport." -ForegroundColor White +Write-Host "" +Write-Host " Clean up later with:" -ForegroundColor White +Write-Host " az group delete --name $rgName --yes" -ForegroundColor Gray +Write-Host "" diff --git a/samples/applications/azure-sql-mcp/infra/main.bicep b/samples/applications/azure-sql-mcp/infra/main.bicep new file mode 100644 index 0000000000..de344b7f8c --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/main.bicep @@ -0,0 +1,207 @@ +targetScope = 'subscription' + +// --------------------------------------------------------------------------- +// Parameters +// --------------------------------------------------------------------------- + +@minLength(1) +@maxLength(64) +@description('Name of the environment (used to generate unique resource names).') +param environmentName string + +@description('Primary location for SQL and other resources.') +@metadata({ + azd: { + type: 'location' + } +}) +param location string + +@description('Location for the Connector Namespace. Preview regions: westcentralus, eastasia, centralus, northeurope.') +param connectorNamespaceLocation string = location + +@description('Object ID of the deployer user. Used as Entra admin for SQL and for access policies.') +@metadata({ + azd: { + type: 'principalId' + } +}) +param deployerPrincipalId string = deployer().objectId + +@description('Login name (email) of the deployer user for SQL Entra admin.') +param deployerLoginName string + +@description('Name of the SQL Database to create.') +param databaseName string = 'mcpdb' + +@description('Optional public IP address to allow through the SQL firewall. The azd post-provision hook also configures this for local setup.') +param deployerIpAddress string = '' + +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +@description('Managed identity type for the Connector Namespace and hosted SQL MCP server.') +param connectorNamespaceIdentityType string + +// --------------------------------------------------------------------------- +// Variables +// --------------------------------------------------------------------------- + +var readableEnvironmentName = take(toLower(replace(replace(replace(environmentName, '_', '-'), '.', '-'), ' ', '-')), 40) +var resourceToken = take(toLower(uniqueString(subscription().id, environmentName, location)), 8) +var tags = { 'azd-env-name': environmentName } +var resourceGroupName = 'rg-${readableEnvironmentName}' +var sqlServerName = 'sql-${readableEnvironmentName}-${resourceToken}' +var connectorNamespaceName = 'cn-${readableEnvironmentName}-${resourceToken}' +var userAssignedIdentityName = 'id-${readableEnvironmentName}-${resourceToken}' +var logAnalyticsWorkspaceName = 'log-${readableEnvironmentName}-${resourceToken}' +var appInsightsName = 'appi-${readableEnvironmentName}-${resourceToken}' +var useUserAssignedIdentity = connectorNamespaceIdentityType == 'UserAssigned' + +// Load the included DAB config and base64-encode it for the ARM API. +var dabConfigBase64 = base64(loadTextContent('../dab-config.json')) + +// --------------------------------------------------------------------------- +// Resource Group +// --------------------------------------------------------------------------- + +resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// --------------------------------------------------------------------------- +// Azure SQL Server + Database +// --------------------------------------------------------------------------- + +module sql './modules/sql.bicep' = { + scope: rg + name: 'sql-${resourceToken}' + params: { + sqlServerName: sqlServerName + databaseName: databaseName + location: location + tags: tags + entraAdminObjectId: deployerPrincipalId + entraAdminLogin: deployerLoginName + deployerIpAddress: deployerIpAddress + } +} + +// --------------------------------------------------------------------------- +// Application Insights +// --------------------------------------------------------------------------- + +module appInsights './modules/appInsights.bicep' = { + scope: rg + name: 'appi-${resourceToken}' + params: { + workspaceName: logAnalyticsWorkspaceName + appInsightsName: appInsightsName + location: 'southcentralus' // TODO: revert to `location` after testing + tags: tags + } +} + +// --------------------------------------------------------------------------- +// Optional user-assigned managed identity +// --------------------------------------------------------------------------- + +module userAssignedIdentity './modules/userAssignedIdentity.bicep' = if (useUserAssignedIdentity) { + scope: rg + name: 'id-${resourceToken}' + params: { + name: userAssignedIdentityName + location: location + tags: tags + } +} + +// --------------------------------------------------------------------------- +// Connector Namespace +// --------------------------------------------------------------------------- + +module connectorNamespace './modules/connectorNamespace.bicep' = { + scope: rg + name: 'cn-${resourceToken}' + params: { + name: connectorNamespaceName + location: connectorNamespaceLocation + tags: tags + identityType: connectorNamespaceIdentityType + userAssignedIdentityResourceId: useUserAssignedIdentity ? userAssignedIdentity.outputs.id : '' + } +} + +var managedIdentityClientId = useUserAssignedIdentity ? userAssignedIdentity.outputs.clientId : '' +var sqlIdentityName = useUserAssignedIdentity ? userAssignedIdentity.outputs.name : connectorNamespace.outputs.name +var sqlIdentityPrincipalId = useUserAssignedIdentity ? userAssignedIdentity.outputs.principalId : connectorNamespace.outputs.principalId +var dabConnectionString = useUserAssignedIdentity + ? 'Server=${sql.outputs.sqlServerFqdn};Database=${databaseName};Authentication=Active Directory Managed Identity;User Id=${managedIdentityClientId};Encrypt=True;TrustServerCertificate=False;' + : 'Server=${sql.outputs.sqlServerFqdn};Database=${databaseName};Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;' + +// --------------------------------------------------------------------------- +// Hosted SQL MCP Server + Access Policy +// --------------------------------------------------------------------------- + +module hostedMcpServer './modules/hostedMcpServer.bicep' = { + scope: rg + name: 'mcp-${resourceToken}' + params: { + connectorNamespaceName: connectorNamespaceName + name: 'sql-mcp' + deployerPrincipalId: deployerPrincipalId + dabConfigBase64: dabConfigBase64 + sqlConnectionString: dabConnectionString + applicationInsightsConnectionString: appInsights.outputs.connectionString + managedIdentityClientId: managedIdentityClientId + } + dependsOn: [ + connectorNamespace + ] +} + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- + +@description('The name of the resource group.') +output RESOURCE_GROUP_NAME string = rg.name + +@description('The name of the SQL Server.') +output SQL_SERVER_NAME string = sql.outputs.sqlServerName + +@description('The fully qualified domain name of the SQL Server.') +output SQL_SERVER_FQDN string = sql.outputs.sqlServerFqdn + +@description('The name of the SQL Database.') +output SQL_DATABASE_NAME string = sql.outputs.databaseName + +@description('The name of the Connector Namespace.') +output CONNECTOR_NAMESPACE_NAME string = connectorNamespace.outputs.name + +@description('The principal ID of the Connector Namespace system-assigned managed identity. Empty when using user-assigned identity only.') +output CONNECTOR_NAMESPACE_PRINCIPAL_ID string = connectorNamespace.outputs.principalId + +@description('The managed identity name granted access to Azure SQL.') +output SQL_IDENTITY_NAME string = sqlIdentityName + +@description('The managed identity principal ID granted access to Azure SQL.') +output SQL_IDENTITY_PRINCIPAL_ID string = sqlIdentityPrincipalId + +@description('The managed identity client ID used by the hosted MCP server. Empty when using system-assigned identity.') +output SQL_IDENTITY_CLIENT_ID string = managedIdentityClientId + +@description('The name of the Application Insights resource.') +output APPLICATIONINSIGHTS_NAME string = appInsights.outputs.appInsightsName + +@description('The name of the Log Analytics workspace backing Application Insights.') +output LOG_ANALYTICS_WORKSPACE_NAME string = appInsights.outputs.workspaceName + +@description('Connection string for DAB config.') +output DAB_CONNECTION_STRING string = dabConnectionString + +@description('MCP endpoint URL — point VS Code / MCP clients here.') +output MCP_ENDPOINT_URL string = hostedMcpServer.outputs.mcpEndpointUrl diff --git a/samples/applications/azure-sql-mcp/infra/main.parameters.json b/samples/applications/azure-sql-mcp/infra/main.parameters.json new file mode 100644 index 0000000000..fa2e41ef88 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/main.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "deployerLoginName": { + "value": "${AZURE_DEPLOYER_LOGIN}" + } + } +} diff --git a/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep b/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep new file mode 100644 index 0000000000..2c48cacf17 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep @@ -0,0 +1,47 @@ +@description('Name for the Log Analytics workspace backing Application Insights.') +param workspaceName string + +@description('Name for the Application Insights resource.') +param appInsightsName string + +@description('Location for monitoring resources.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: workspaceName + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: appInsightsName + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +@description('The name of the Log Analytics workspace.') +output workspaceName string = workspace.name + +@description('The name of the Application Insights resource.') +output appInsightsName string = appInsights.name + +@description('The Application Insights connection string.') +output connectionString string = appInsights.properties.ConnectionString diff --git a/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep b/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep new file mode 100644 index 0000000000..242bdfee84 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep @@ -0,0 +1,44 @@ +@description('Name for the Connector Namespace.') +param name string + +@description('Location for the Connector Namespace. During preview, only select regions are supported: westcentralus, eastasia, centralus, northeurope.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +@description('Managed identity type for the Connector Namespace.') +param identityType string = 'SystemAssigned' + +@description('Resource ID of the user-assigned managed identity to attach when identityType is UserAssigned.') +param userAssignedIdentityResourceId string = '' + +var identityBlock = identityType == 'UserAssigned' ? { + type: 'UserAssigned' + userAssignedIdentities: { + '${userAssignedIdentityResourceId}': {} + } +} : { + type: 'SystemAssigned' +} + +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' = { + name: name + location: location + tags: tags + identity: identityBlock + properties: {} +} + +@description('The resource ID of the Connector Namespace.') +output resourceId string = connectorNamespace.id + +@description('The name of the Connector Namespace.') +output name string = connectorNamespace.name + +@description('The principal ID of the system-assigned managed identity. Empty when using user-assigned identity only.') +output principalId string = identityType == 'SystemAssigned' ? connectorNamespace.identity.principalId : '' diff --git a/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep b/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep new file mode 100644 index 0000000000..f0950b1469 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep @@ -0,0 +1,82 @@ +@sys.description('Name of the parent Connector Namespace.') +param connectorNamespaceName string + +@sys.description('Name for the MCP server config (2-64 chars).') +@minLength(2) +@maxLength(64) +param name string + +@sys.description('Description shown to MCP clients.') +param mcpServerDescription string = 'SQL MCP server bound to DAB config with managed identity.' + +@sys.description('Object ID of the deployer user to grant MCP access.') +param deployerPrincipalId string + +@sys.description('Tenant ID for access policies.') +param tenantId string = tenant().tenantId + +@sys.description('Base64-encoded DAB configuration file content.') +param dabConfigBase64 string + +@secure() +@sys.description('SQL connection string exposed to the hosted MCP server as SQL_CONNECTION_STRING.') +param sqlConnectionString string + +@sys.description('Application Insights connection string exposed to the hosted MCP server as APPLICATIONINSIGHTS_CONNECTION_STRING.') +param applicationInsightsConnectionString string + +@sys.description('Optional managed identity client ID exposed as AZURE_CLIENT_ID when using a user-assigned managed identity.') +param managedIdentityClientId string = '' + +// Reference the existing Connector Namespace +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' existing = { + name: connectorNamespaceName +} + +var hostedMcpConfiguration = union({ + configFile: dabConfigBase64 + SQL_CONNECTION_STRING: sqlConnectionString + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsightsConnectionString +}, empty(managedIdentityClientId) ? {} : { + AZURE_CLIENT_ID: managedIdentityClientId +}) + +// Hosted MCP Server — runs the curated mcp-sql container image with DAB config +resource mcpServer 'Microsoft.Web/connectorGateways/mcpServerConfigs@2026-05-01-preview' = { + parent: connectorNamespace + name: name + kind: 'HostedMcpServer' + properties: { + description: mcpServerDescription + hostedMcpServer: { + hostedMcpServerId: 'mcp-sql' + configuration: hostedMcpConfiguration + } + } +} + +// Grant the deployer access to invoke the MCP server tools. +// The access-policy name must equal the principal's objectId. +resource mcpAccessPolicy 'Microsoft.Web/connectorGateways/mcpServerConfigs/accessPolicies@2026-05-01-preview' = { + parent: mcpServer + name: deployerPrincipalId + properties: { + principal: { + type: 'ActiveDirectory' + identity: { + objectId: deployerPrincipalId + tenantId: tenantId + } + } + principalType: 'User' + } +} + +@sys.description('Resource ID of the MCP server config.') +output id string = mcpServer.id + +@sys.description('Name of the MCP server config.') +output mcpServerName string = mcpServer.name + +@sys.description('MCP endpoint URL for clients to connect to.') +output mcpEndpointUrl string = mcpServer.properties.mcpEndpointUrl diff --git a/samples/applications/azure-sql-mcp/infra/modules/sql.bicep b/samples/applications/azure-sql-mcp/infra/modules/sql.bicep new file mode 100644 index 0000000000..c345536b5e --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/sql.bicep @@ -0,0 +1,86 @@ +@description('Azure SQL Server name.') +param sqlServerName string + +@description('Azure SQL Database name.') +param databaseName string + +@description('Location for resources.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +@description('Object ID of the Entra ID admin for the SQL Server.') +param entraAdminObjectId string + +@description('Login name of the Entra ID admin (email or display name).') +param entraAdminLogin string + +@description('Tenant ID for Entra ID authentication.') +param tenantId string = tenant().tenantId + +@description('Public IP of the deployer for SQL firewall rule (allows post-provision scripts to connect). Leave empty to skip.') +param deployerIpAddress string = '' + +resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: sqlServerName + location: location + tags: tags + properties: { + administrators: { + administratorType: 'ActiveDirectory' + azureADOnlyAuthentication: true + login: entraAdminLogin + sid: entraAdminObjectId + tenantId: tenantId + principalType: 'User' + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + } +} + +// Allow Azure services to access the SQL Server +resource firewallRuleAzure 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: sqlServer + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +// Allow the deployer's IP to connect for post-provision seeding/granting +resource firewallRuleDeployer 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = if (!empty(deployerIpAddress)) { + parent: sqlServer + name: 'AllowDeployerIp' + properties: { + startIpAddress: deployerIpAddress + endIpAddress: deployerIpAddress + } +} + +resource database 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + parent: sqlServer + name: databaseName + location: location + tags: tags + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: 2147483648 + } +} + +@description('The fully qualified domain name of the SQL Server.') +output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName + +@description('The name of the SQL Server.') +output sqlServerName string = sqlServer.name + +@description('The name of the SQL Database.') +output databaseName string = database.name diff --git a/samples/applications/azure-sql-mcp/infra/modules/userAssignedIdentity.bicep b/samples/applications/azure-sql-mcp/infra/modules/userAssignedIdentity.bicep new file mode 100644 index 0000000000..4ced3d435b --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/userAssignedIdentity.bicep @@ -0,0 +1,26 @@ +@description('Name for the user-assigned managed identity.') +param name string + +@description('Location for the user-assigned managed identity.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +@description('The resource ID of the user-assigned managed identity.') +output id string = identity.id + +@description('The name of the user-assigned managed identity.') +output name string = identity.name + +@description('The client ID of the user-assigned managed identity.') +output clientId string = identity.properties.clientId + +@description('The principal ID of the user-assigned managed identity.') +output principalId string = identity.properties.principalId diff --git a/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 b/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 new file mode 100644 index 0000000000..ab1c98ab3d --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 @@ -0,0 +1,264 @@ +#!/usr/bin/env pwsh +# post-provision.ps1 — Runs after `azd provision` to seed the database and +# generate the DAB config file. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Add-SqlFirewallRuleForIp { + param( + [Parameter(Mandatory)] + [string]$IpAddress, + + [Parameter(Mandatory)] + [string]$RuleName + ) + + az sql server firewall-rule create ` + --resource-group $resourceGroupName ` + --server $sqlServerName ` + --name $RuleName ` + --start-ip-address $IpAddress ` + --end-ip-address $IpAddress ` + --only-show-errors | Out-Null +} + +function Invoke-SqlcmdWithFirewallRetry { + param( + [Parameter(Mandatory)] + [string]$Query + ) + + $maxAttempts = 20 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $databaseName -AccessToken $token -Query $Query + return + } catch { + $message = $_.Exception.Message + if ($message -match "Client with IP address '([^']+)' is not allowed") { + $blockedIp = $Matches[1] + Write-Host " SQL reported blocked client IP $blockedIp; adding firewall rule and retrying ($attempt/$maxAttempts)..." -ForegroundColor Yellow + Add-SqlFirewallRuleForIp -IpAddress $blockedIp -RuleName 'AllowSqlClientIp' + Start-Sleep -Seconds 15 + continue + } + + throw + } + } + + throw "Timed out waiting for SQL firewall rules to allow this client." +} + +# Read outputs from azd +$resourceGroupName = (azd env get-value RESOURCE_GROUP_NAME) +$sqlServerName = (azd env get-value SQL_SERVER_NAME) +$sqlServerFqdn = (azd env get-value SQL_SERVER_FQDN) +$databaseName = (azd env get-value SQL_DATABASE_NAME) +$connectorNsName = (azd env get-value CONNECTOR_NAMESPACE_NAME) +$connectorNsPrincipal = (azd env get-value CONNECTOR_NAMESPACE_PRINCIPAL_ID) +$sqlIdentityName = (azd env get-value SQL_IDENTITY_NAME) +$sqlIdentityPrincipal = (azd env get-value SQL_IDENTITY_PRINCIPAL_ID) +$dabConnectionString = (azd env get-value DAB_CONNECTION_STRING) + +if (-not $sqlIdentityName) { + $sqlIdentityName = $connectorNsName +} +if (-not $sqlIdentityPrincipal) { + $sqlIdentityPrincipal = $connectorNsPrincipal +} + +Write-Host "" +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host " Post-Provision Setup" -ForegroundColor Cyan +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "SQL Server: $sqlServerFqdn" +Write-Host "Database: $databaseName" +Write-Host "Connector Namespace: $connectorNsName" +Write-Host "Connector NS SAMI ID: $connectorNsPrincipal" +Write-Host "SQL MI User: $sqlIdentityName" +Write-Host "SQL MI Principal ID: $sqlIdentityPrincipal" +Write-Host "" + +# --- Step 1: Allow this machine through the SQL firewall --- +Write-Host "[1/4] Configuring SQL firewall for this machine..." -ForegroundColor Yellow +try { + $detectedIps = @() + foreach ($uri in @('https://api.ipify.org', 'https://ifconfig.me/ip')) { + try { + $ip = (Invoke-RestMethod -Uri $uri -TimeoutSec 10) + if ($ip -and $ip -match '^\d{1,3}(\.\d{1,3}){3}$') { + $detectedIps += $ip + } + } catch { + Write-Host " Could not detect public IP from $uri." -ForegroundColor DarkYellow + } + } + + $index = 0 + foreach ($deployerIp in ($detectedIps | Select-Object -Unique)) { + $index++ + $ruleName = if ($index -eq 1) { 'AllowDeployerIp' } else { "AllowDeployerIp$index" } + Add-SqlFirewallRuleForIp -IpAddress $deployerIp -RuleName $ruleName + Write-Host " Allowed public IP: $deployerIp" -ForegroundColor Green + } +} catch { + Write-Host " WARNING: Could not detect or configure public IP. SQL commands may need to be run manually." -ForegroundColor Yellow +} + +# --- Step 2: Get an access token for Azure SQL --- +Write-Host "[2/4] Getting access token for Azure SQL..." -ForegroundColor Yellow +$token = az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv +if (-not $token) { + Write-Host "ERROR: Failed to get access token. Make sure you are logged in with 'az login'." -ForegroundColor Red + exit 1 +} + +# --- Step 3: Seed the database --- +Write-Host "[3/4] Seeding the database with BlogPosts table..." -ForegroundColor Yellow + +$seedSql = @" +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END +PRINT 'BlogPosts table seeded.'; +"@ + +$grantSql = @" +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$sqlIdentityName') +BEGIN + CREATE USER [$sqlIdentityName] FROM EXTERNAL PROVIDER; +END +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$sqlIdentityName'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [$sqlIdentityName]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$sqlIdentityName'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [$sqlIdentityName]; +GRANT VIEW DEFINITION TO [$sqlIdentityName]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$sqlIdentityName') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$sqlIdentityName'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$sqlIdentityName'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1; +PRINT 'Granted managed identity access to database.'; +"@ + +if (-not (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue)) { + Write-Host "ERROR: Invoke-Sqlcmd is required for automatic post-provision database setup." -ForegroundColor Red + Write-Host "Install the SqlServer PowerShell module or run the SQL commands manually in Azure Portal Query Editor." -ForegroundColor Red + exit 1 +} + +Invoke-SqlcmdWithFirewallRetry -Query $seedSql +Write-Host " Database seeded successfully." -ForegroundColor Green + +Write-Host "[4/4] Granting managed identity access to database..." -ForegroundColor Yellow +Invoke-SqlcmdWithFirewallRetry -Query $grantSql +Write-Host " Managed identity access granted." -ForegroundColor Green + +# --- Generate DAB config --- +Write-Host "" +Write-Host "Generating dab-config.json..." -ForegroundColor Yellow + +$dabConfig = @{ + '$schema' = 'https://github.com/Azure/data-api-builder/releases/download/v1.7.93/dab.draft.schema.json' + 'data-source' = @{ + 'database-type' = 'mssql' + 'connection-string' = $dabConnectionString + 'options' = @{ + 'set-session-context' = $false + } + } + 'runtime' = @{ + 'rest' = @{ + 'enabled' = $false + 'path' = '/api' + 'request-body-strict' = $true + } + 'graphql' = @{ + 'enabled' = $false + 'path' = '/graphql' + 'allow-introspection' = $true + } + 'mcp' = @{ + 'enabled' = $true + 'path' = '/mcp' + } + 'host' = @{ + 'cors' = @{ + 'origins' = @() + 'allow-credentials' = $false + } + 'authentication' = @{ + 'provider' = 'AppService' + } + 'mode' = 'development' + } + } + 'entities' = @{ + 'BlogPosts' = @{ + 'source' = @{ + 'object' = 'dbo.BlogPosts' + 'type' = 'table' + } + 'graphql' = @{ + 'enabled' = $true + 'type' = @{ + 'singular' = 'BlogPosts' + 'plural' = 'BlogPosts' + } + } + 'rest' = @{ + 'enabled' = $true + } + 'permissions' = @( + @{ + 'role' = 'anonymous' + 'actions' = @( + @{ 'action' = '*' } + ) + } + ) + } + } +} | ConvertTo-Json -Depth 10 + +$dabConfig | Set-Content -Path (Join-Path $PSScriptRoot '..' 'dab-config.generated.json') -Encoding utf8 +Write-Host " Created dab-config.generated.json" -ForegroundColor Green + +# --- Print MCP endpoint and portal link --- +$mcpEndpointUrl = (azd env get-value MCP_ENDPOINT_URL) +$subscriptionId = (azd env get-value AZURE_SUBSCRIPTION_ID) +if (-not $subscriptionId) { + $subscriptionId = az account show --query id -o tsv +} +$resourceGroupUrl = "https://portal.azure.com/#@/resource/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/overview" + +Write-Host "" +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host " Deployment Complete!" -ForegroundColor Cyan +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "MCP Endpoint: $mcpEndpointUrl" -ForegroundColor Green +Write-Host "Azure Portal: $resourceGroupUrl" -ForegroundColor Green +Write-Host "" +Write-Host "Use the MCP endpoint with any MCP client that supports HTTP transport." -ForegroundColor White +Write-Host "" diff --git a/samples/applications/azure-sql-mcp/scripts/post-provision.sh b/samples/applications/azure-sql-mcp/scripts/post-provision.sh new file mode 100755 index 0000000000..046a4e0410 --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/post-provision.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# post-provision.sh — Runs after `azd provision` to seed the database and +# generate the DAB config file. + +set -euo pipefail + +add_sql_firewall_rule_for_ip() { + local ip_address="$1" + local rule_name="$2" + + az sql server firewall-rule create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --server "$SQL_SERVER_NAME" \ + --name "$rule_name" \ + --start-ip-address "$ip_address" \ + --end-ip-address "$ip_address" \ + --only-show-errors >/dev/null +} + +run_sqlcmd_with_firewall_retry() { + local query="$1" + local output + local max_attempts=20 + local attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + set +e + output=$(echo "$query" | sqlcmd -S "$SQL_SERVER_FQDN" -d "$DATABASE_NAME" -G 2>&1) + local exit_code=$? + set -e + + if [ "$exit_code" -eq 0 ]; then + echo "$output" + return 0 + fi + + if [[ "$output" =~ Client\ with\ IP\ address\ \'([0-9.]+)\'\ is\ not\ allowed ]]; then + local blocked_ip="${BASH_REMATCH[1]}" + echo " SQL reported blocked client IP $blocked_ip; adding firewall rule and retrying ($attempt/$max_attempts)..." + add_sql_firewall_rule_for_ip "$blocked_ip" "AllowSqlClientIp" + sleep 15 + attempt=$((attempt + 1)) + continue + fi + + echo "$output" + return "$exit_code" + done + + echo "Timed out waiting for SQL firewall rules to allow this client." + return 1 +} + +# Read outputs from azd +RESOURCE_GROUP_NAME=$(azd env get-value RESOURCE_GROUP_NAME) +SQL_SERVER_NAME=$(azd env get-value SQL_SERVER_NAME) +SQL_SERVER_FQDN=$(azd env get-value SQL_SERVER_FQDN) +DATABASE_NAME=$(azd env get-value SQL_DATABASE_NAME) +CONNECTOR_NS_NAME=$(azd env get-value CONNECTOR_NAMESPACE_NAME) +CONNECTOR_NS_PRINCIPAL=$(azd env get-value CONNECTOR_NAMESPACE_PRINCIPAL_ID) +SQL_IDENTITY_NAME=$(azd env get-value SQL_IDENTITY_NAME || true) +SQL_IDENTITY_PRINCIPAL=$(azd env get-value SQL_IDENTITY_PRINCIPAL_ID || true) +DAB_CONNECTION_STRING=$(azd env get-value DAB_CONNECTION_STRING) + +if [ -z "$SQL_IDENTITY_NAME" ]; then + SQL_IDENTITY_NAME="$CONNECTOR_NS_NAME" +fi +if [ -z "$SQL_IDENTITY_PRINCIPAL" ]; then + SQL_IDENTITY_PRINCIPAL="$CONNECTOR_NS_PRINCIPAL" +fi + +echo "" +echo "============================================================" +echo " Post-Provision Setup" +echo "============================================================" +echo "" +echo "SQL Server: $SQL_SERVER_FQDN" +echo "Database: $DATABASE_NAME" +echo "Connector Namespace: $CONNECTOR_NS_NAME" +echo "Connector NS SAMI ID: $CONNECTOR_NS_PRINCIPAL" +echo "SQL MI User: $SQL_IDENTITY_NAME" +echo "SQL MI Principal ID: $SQL_IDENTITY_PRINCIPAL" +echo "" + +# --- Step 1: Allow this machine through the SQL firewall --- +echo "[1/4] Configuring SQL firewall for this machine..." +DETECTED_IPS=() +for IP_ENDPOINT in "https://api.ipify.org" "https://ifconfig.me/ip"; do + DETECTED_IP=$(curl -s --max-time 10 "$IP_ENDPOINT" || true) + if [[ "$DETECTED_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + DETECTED_IPS+=("$DETECTED_IP") + fi +done + +if [ "${#DETECTED_IPS[@]}" -eq 0 ]; then + echo " WARNING: Could not detect public IP. SQL commands may need to be run manually." +else + INDEX=0 + printf "%s\n" "${DETECTED_IPS[@]}" | sort -u | while read -r DEPLOYER_IP; do + INDEX=$((INDEX + 1)) + RULE_NAME="AllowDeployerIp" + if [ "$INDEX" -gt 1 ]; then + RULE_NAME="AllowDeployerIp$INDEX" + fi + add_sql_firewall_rule_for_ip "$DEPLOYER_IP" "$RULE_NAME" + echo " Allowed public IP: $DEPLOYER_IP" + done +fi + +# --- Step 2: Get an access token for Azure SQL --- +echo "[2/4] Getting access token for Azure SQL..." +TOKEN=$(az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv) +if [ -z "$TOKEN" ]; then + echo "ERROR: Failed to get access token. Make sure you are logged in with 'az login'." + exit 1 +fi + +# --- Step 3: Seed the database --- +echo "[3/4] Seeding the database with BlogPosts table..." + +SEED_SQL="IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END; +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END; +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END;" + +GRANT_SQL="IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '${SQL_IDENTITY_NAME}') +BEGIN + CREATE USER [${SQL_IDENTITY_NAME}] FROM EXTERNAL PROVIDER; +END; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '${SQL_IDENTITY_NAME}'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [${SQL_IDENTITY_NAME}]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '${SQL_IDENTITY_NAME}'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [${SQL_IDENTITY_NAME}]; +GRANT VIEW DEFINITION TO [${SQL_IDENTITY_NAME}]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '${SQL_IDENTITY_NAME}') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '${SQL_IDENTITY_NAME}'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '${SQL_IDENTITY_NAME}'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1;" + +if command -v sqlcmd &> /dev/null; then + run_sqlcmd_with_firewall_retry "$SEED_SQL" + echo " Database seeded." + echo "[4/4] Granting managed identity access..." + run_sqlcmd_with_firewall_retry "$GRANT_SQL" + echo " Managed identity access granted." +else + echo "WARNING: sqlcmd not found. Please run these SQL commands in the Azure Portal Query Editor:" + echo "" + echo "$SEED_SQL" + echo "" + echo "$GRANT_SQL" + exit 1 +fi + +# --- Generate DAB config --- +echo "" +echo "Generating dab-config.generated.json..." + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cat > "$SCRIPT_DIR/../dab-config.generated.json" <$null +if (-not $currentLogin) { + Write-Host "Detecting deployer login from Azure CLI..." -ForegroundColor Yellow + $login = az account show --query user.name -o tsv + if ($login) { + azd env set AZURE_DEPLOYER_LOGIN $login + Write-Host " Set AZURE_DEPLOYER_LOGIN=$login" -ForegroundColor Green + } else { + Write-Host "ERROR: Could not detect login. Run: azd env set AZURE_DEPLOYER_LOGIN your-email@example.com" -ForegroundColor Red + exit 1 + } +} + +# Auto-detect deployer public IP for SQL firewall +$currentIp = azd env get-value AZURE_DEPLOYER_IP 2>$null +if (-not $currentIp) { + Write-Host "Detecting deployer public IP..." -ForegroundColor Yellow + try { + $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 10) + azd env set AZURE_DEPLOYER_IP $ip + Write-Host " Set AZURE_DEPLOYER_IP=$ip" -ForegroundColor Green + } catch { + Write-Host "WARNING: Could not detect public IP. SQL post-provision scripts may fail." -ForegroundColor Yellow + azd env set AZURE_DEPLOYER_IP '' + } +} diff --git a/samples/applications/azure-sql-mcp/scripts/pre-provision.sh b/samples/applications/azure-sql-mcp/scripts/pre-provision.sh new file mode 100755 index 0000000000..510212acc1 --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/pre-provision.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# pre-provision.sh — Auto-detect deployer login and public IP before provisioning. + +set -euo pipefail + +# Auto-detect deployer login (email) if not already set +CURRENT_LOGIN=$(azd env get-value AZURE_DEPLOYER_LOGIN 2>/dev/null || true) +if [ -z "$CURRENT_LOGIN" ]; then + echo "Detecting deployer login from Azure CLI..." + LOGIN=$(az account show --query user.name -o tsv) + if [ -n "$LOGIN" ]; then + azd env set AZURE_DEPLOYER_LOGIN "$LOGIN" + echo " Set AZURE_DEPLOYER_LOGIN=$LOGIN" + else + echo "ERROR: Could not detect login. Run: azd env set AZURE_DEPLOYER_LOGIN your-email@example.com" + exit 1 + fi +fi + +# Auto-detect deployer public IP for SQL firewall +CURRENT_IP=$(azd env get-value AZURE_DEPLOYER_IP 2>/dev/null || true) +if [ -z "$CURRENT_IP" ]; then + echo "Detecting deployer public IP..." + IP=$(curl -s --max-time 10 https://api.ipify.org || true) + if [ -n "$IP" ]; then + azd env set AZURE_DEPLOYER_IP "$IP" + echo " Set AZURE_DEPLOYER_IP=$IP" + else + echo "WARNING: Could not detect public IP. SQL post-provision scripts may fail." + azd env set AZURE_DEPLOYER_IP "" + fi +fi diff --git a/samples/applications/iot-connected-car/App.config b/samples/applications/iot-connected-car/App.config index 7040d5a510..a91b941664 100644 --- a/samples/applications/iot-connected-car/App.config +++ b/samples/applications/iot-connected-car/App.config @@ -1,21 +1,21 @@ - + - + - + - - - - - - - - - + + + + + + + + + diff --git a/samples/applications/iot-connected-car/DataGenerator/DataGenerator.csproj b/samples/applications/iot-connected-car/DataGenerator/DataGenerator.csproj index 56057d7a71..c2299b3a09 100644 --- a/samples/applications/iot-connected-car/DataGenerator/DataGenerator.csproj +++ b/samples/applications/iot-connected-car/DataGenerator/DataGenerator.csproj @@ -9,8 +9,9 @@ Properties DataGenerator DataGenerator - v4.5.2 + v4.8 512 + true diff --git a/samples/applications/iot-connected-car/WinFormsClient/Properties/Resources.Designer.cs b/samples/applications/iot-connected-car/WinFormsClient/Properties/Resources.Designer.cs index 6d43c482d7..0b60deafdf 100644 --- a/samples/applications/iot-connected-car/WinFormsClient/Properties/Resources.Designer.cs +++ b/samples/applications/iot-connected-car/WinFormsClient/Properties/Resources.Designer.cs @@ -10,8 +10,8 @@ namespace Client.Properties { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -19,19 +19,19 @@ namespace Client.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { - + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Resources() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// @@ -45,7 +45,7 @@ internal Resources() { return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. diff --git a/samples/applications/iot-connected-car/WinFormsClient/Properties/Settings.Designer.cs b/samples/applications/iot-connected-car/WinFormsClient/Properties/Settings.Designer.cs index 6ec15e636f..c0c8deb1c0 100644 --- a/samples/applications/iot-connected-car/WinFormsClient/Properties/Settings.Designer.cs +++ b/samples/applications/iot-connected-car/WinFormsClient/Properties/Settings.Designer.cs @@ -8,21 +8,17 @@ // //------------------------------------------------------------------------------ -namespace Client.Properties -{ - - +namespace Client.Properties { + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase - { - + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.11.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default - { - get - { + + public static Settings Default { + get { return defaultInstance; } } diff --git a/samples/applications/iot-connected-car/WinFormsClient/WinFormsClient.csproj b/samples/applications/iot-connected-car/WinFormsClient/WinFormsClient.csproj index a69b731265..38f3d55db7 100644 --- a/samples/applications/iot-connected-car/WinFormsClient/WinFormsClient.csproj +++ b/samples/applications/iot-connected-car/WinFormsClient/WinFormsClient.csproj @@ -9,9 +9,10 @@ Properties Client Client - v4.5.2 + v4.8 512 true + AnyCPU diff --git a/samples/databases/adventure-works/oltp-install-script/instawdb.sql b/samples/databases/adventure-works/oltp-install-script/instawdb.sql index 4a7a8694a3..86d6e5f8b9 100644 --- a/samples/databases/adventure-works/oltp-install-script/instawdb.sql +++ b/samples/databases/adventure-works/oltp-install-script/instawdb.sql @@ -84,12 +84,19 @@ PRINT ''; PRINT '*** Dropping Database'; GO -IF EXISTS (SELECT [name] FROM [master].[sys].[databases] WHERE [name] = N'$(DatabaseName)') - DROP DATABASE $(DatabaseName); +DECLARE @DBName NVARCHAR(128) = N'$(DatabaseName)'; --- If the database has any other open connections close the network connection. -IF @@ERROR = 3702 - RAISERROR('$(DatabaseName) database cannot be dropped because there are still other open connections', 127, 127) WITH NOWAIT, LOG; +IF EXISTS (SELECT [name] FROM [master].[sys].[databases] WHERE [name] = @DBName) +BEGIN + -- Close existing connections to the database + DECLARE @SQL NVARCHAR(MAX) = N''; + SELECT @SQL += 'ALTER DATABASE [' + @DBName + '] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + EXEC sp_executesql @SQL; + + -- Drop the database + SET @SQL = N'DROP DATABASE [' + @DBName + '];'; + EXEC sp_executesql @SQL; +END GO -- **************************************** diff --git a/samples/databases/futon-manufacturing/01-schema.sql b/samples/databases/futon-manufacturing/01-schema.sql new file mode 100644 index 0000000000..6a142161fb --- /dev/null +++ b/samples/databases/futon-manufacturing/01-schema.sql @@ -0,0 +1,369 @@ +-- ============================================= +-- Futon Manufacturing Database Schema +-- ============================================= +-- This database manages a futon manufacturing business with multi-level +-- bill of materials, inventory, production, and sales tracking. +-- ============================================= + +USE master; +GO + +-- Drop database if exists +IF EXISTS (SELECT name FROM sys.databases WHERE name = N'FutonManufacturing') +BEGIN + ALTER DATABASE FutonManufacturing SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE FutonManufacturing; +END +GO + +CREATE DATABASE FutonManufacturing; +GO + +USE FutonManufacturing; +GO + +-- ============================================= +-- Reference Tables +-- ============================================= + +-- Unit of Measure +CREATE TABLE UnitOfMeasure ( + UnitID INT IDENTITY(1,1) PRIMARY KEY, + UnitCode NVARCHAR(10) NOT NULL UNIQUE, + UnitName NVARCHAR(50) NOT NULL, + Description NVARCHAR(255) +); + +-- Item Types (Raw Material, Component, Finished Good) +CREATE TABLE ItemType ( + ItemTypeID INT IDENTITY(1,1) PRIMARY KEY, + TypeCode NVARCHAR(20) NOT NULL UNIQUE, + TypeName NVARCHAR(100) NOT NULL, + Description NVARCHAR(255) +); + +-- ============================================= +-- Items Master Table +-- ============================================= + +CREATE TABLE Items ( + ItemID INT IDENTITY(1,1) PRIMARY KEY, + ItemCode NVARCHAR(50) NOT NULL UNIQUE, + ItemName NVARCHAR(255) NOT NULL, + ItemTypeID INT NOT NULL, + UnitID INT NOT NULL, + Description NVARCHAR(MAX), + StandardCost DECIMAL(18,4) NOT NULL DEFAULT 0, + ListPrice DECIMAL(18,4) NOT NULL DEFAULT 0, + IsActive BIT NOT NULL DEFAULT 1, + LeadTimeDays INT DEFAULT 0, + ReorderPoint DECIMAL(18,2) DEFAULT 0, + SafetyStock DECIMAL(18,2) DEFAULT 0, + CreatedDate DATETIME2 DEFAULT GETDATE(), + ModifiedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_Items_ItemType FOREIGN KEY (ItemTypeID) REFERENCES ItemType(ItemTypeID), + CONSTRAINT FK_Items_UnitOfMeasure FOREIGN KEY (UnitID) REFERENCES UnitOfMeasure(UnitID) +); + +-- ============================================= +-- Bill of Materials (Multi-Level) +-- ============================================= + +CREATE TABLE BillOfMaterials ( + BOMID INT IDENTITY(1,1) PRIMARY KEY, + ParentItemID INT NOT NULL, + ComponentItemID INT NOT NULL, + Quantity DECIMAL(18,4) NOT NULL, + UnitID INT NOT NULL, + ScrapRate DECIMAL(5,2) DEFAULT 0, -- Percentage + EffectiveDate DATE DEFAULT CAST(GETDATE() AS DATE), + EndDate DATE NULL, + BOMLevel INT NOT NULL DEFAULT 0, -- 0 = top level, increases for sub-components + IsActive BIT NOT NULL DEFAULT 1, + Notes NVARCHAR(MAX), + CreatedDate DATETIME2 DEFAULT GETDATE(), + ModifiedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_BOM_ParentItem FOREIGN KEY (ParentItemID) REFERENCES Items(ItemID), + CONSTRAINT FK_BOM_ComponentItem FOREIGN KEY (ComponentItemID) REFERENCES Items(ItemID), + CONSTRAINT FK_BOM_Unit FOREIGN KEY (UnitID) REFERENCES UnitOfMeasure(UnitID), + CONSTRAINT CHK_BOM_NotSelf CHECK (ParentItemID <> ComponentItemID) +); + +-- Index for BOM queries +CREATE NONCLUSTERED INDEX IX_BOM_Parent ON BillOfMaterials(ParentItemID) INCLUDE (ComponentItemID, Quantity); +CREATE NONCLUSTERED INDEX IX_BOM_Component ON BillOfMaterials(ComponentItemID); + +-- ============================================= +-- Inventory Management +-- ============================================= + +CREATE TABLE Warehouse ( + WarehouseID INT IDENTITY(1,1) PRIMARY KEY, + WarehouseCode NVARCHAR(20) NOT NULL UNIQUE, + WarehouseName NVARCHAR(100) NOT NULL, + Address NVARCHAR(255), + City NVARCHAR(100), + State NVARCHAR(50), + ZipCode NVARCHAR(20), + IsActive BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE Inventory ( + InventoryID INT IDENTITY(1,1) PRIMARY KEY, + ItemID INT NOT NULL, + WarehouseID INT NOT NULL, + QuantityOnHand DECIMAL(18,2) NOT NULL DEFAULT 0, + QuantityAllocated DECIMAL(18,2) NOT NULL DEFAULT 0, + QuantityAvailable AS (QuantityOnHand - QuantityAllocated) PERSISTED, + LastCountDate DATETIME2, + LastUpdated DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_Inventory_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID), + CONSTRAINT FK_Inventory_Warehouse FOREIGN KEY (WarehouseID) REFERENCES Warehouse(WarehouseID), + CONSTRAINT UQ_Inventory_Item_Warehouse UNIQUE (ItemID, WarehouseID) +); + +CREATE TABLE TransactionType ( + TransactionTypeID INT IDENTITY(1,1) PRIMARY KEY, + TypeCode NVARCHAR(20) NOT NULL UNIQUE, + TypeName NVARCHAR(100) NOT NULL, + Description NVARCHAR(255) +); + +CREATE TABLE InventoryTransaction ( + TransactionID INT IDENTITY(1,1) PRIMARY KEY, + ItemID INT NOT NULL, + WarehouseID INT NOT NULL, + TransactionTypeID INT NOT NULL, + Quantity DECIMAL(18,2) NOT NULL, + UnitCost DECIMAL(18,4), + ReferenceNumber NVARCHAR(50), + ReferenceType NVARCHAR(50), -- PO, SO, WO, ADJ, etc. + Notes NVARCHAR(MAX), + TransactionDate DATETIME2 DEFAULT GETDATE(), + CreatedBy NVARCHAR(100), + CONSTRAINT FK_InvTrans_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID), + CONSTRAINT FK_InvTrans_Warehouse FOREIGN KEY (WarehouseID) REFERENCES Warehouse(WarehouseID), + CONSTRAINT FK_InvTrans_Type FOREIGN KEY (TransactionTypeID) REFERENCES TransactionType(TransactionTypeID) +); + +CREATE NONCLUSTERED INDEX IX_InvTrans_Date ON InventoryTransaction(TransactionDate DESC); +CREATE NONCLUSTERED INDEX IX_InvTrans_Item ON InventoryTransaction(ItemID, TransactionDate); + +-- ============================================= +-- Supplier Management +-- ============================================= + +CREATE TABLE Supplier ( + SupplierID INT IDENTITY(1,1) PRIMARY KEY, + SupplierCode NVARCHAR(20) NOT NULL UNIQUE, + SupplierName NVARCHAR(255) NOT NULL, + ContactName NVARCHAR(100), + Email NVARCHAR(100), + Phone NVARCHAR(20), + Address NVARCHAR(255), + City NVARCHAR(100), + State NVARCHAR(50), + ZipCode NVARCHAR(20), + Country NVARCHAR(50), + PaymentTerms NVARCHAR(50), + Rating DECIMAL(3,2), -- 0.00 to 5.00 + IsActive BIT NOT NULL DEFAULT 1, + CreatedDate DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE SupplierItem ( + SupplierItemID INT IDENTITY(1,1) PRIMARY KEY, + SupplierID INT NOT NULL, + ItemID INT NOT NULL, + SupplierPartNumber NVARCHAR(50), + UnitPrice DECIMAL(18,4) NOT NULL, + MinimumOrderQuantity DECIMAL(18,2) DEFAULT 1, + LeadTimeDays INT DEFAULT 0, + IsPreferred BIT NOT NULL DEFAULT 0, + EffectiveDate DATE DEFAULT CAST(GETDATE() AS DATE), + EndDate DATE NULL, + CONSTRAINT FK_SupplierItem_Supplier FOREIGN KEY (SupplierID) REFERENCES Supplier(SupplierID), + CONSTRAINT FK_SupplierItem_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +CREATE TABLE PurchaseOrder ( + PurchaseOrderID INT IDENTITY(1,1) PRIMARY KEY, + PONumber NVARCHAR(50) NOT NULL UNIQUE, + SupplierID INT NOT NULL, + WarehouseID INT NOT NULL, + OrderDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + ExpectedDeliveryDate DATE, + ActualDeliveryDate DATE, + Status NVARCHAR(20) NOT NULL DEFAULT 'Draft', -- Draft, Submitted, Confirmed, Shipped, Received, Cancelled + Subtotal DECIMAL(18,2) DEFAULT 0, + TaxAmount DECIMAL(18,2) DEFAULT 0, + ShippingAmount DECIMAL(18,2) DEFAULT 0, + TotalAmount DECIMAL(18,2) DEFAULT 0, + Notes NVARCHAR(MAX), + CreatedBy NVARCHAR(100), + CreatedDate DATETIME2 DEFAULT GETDATE(), + ModifiedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_PO_Supplier FOREIGN KEY (SupplierID) REFERENCES Supplier(SupplierID), + CONSTRAINT FK_PO_Warehouse FOREIGN KEY (WarehouseID) REFERENCES Warehouse(WarehouseID) +); + +CREATE TABLE PurchaseOrderDetail ( + PODetailID INT IDENTITY(1,1) PRIMARY KEY, + PurchaseOrderID INT NOT NULL, + LineNumber INT NOT NULL, + ItemID INT NOT NULL, + Quantity DECIMAL(18,2) NOT NULL, + UnitPrice DECIMAL(18,4) NOT NULL, + QuantityReceived DECIMAL(18,2) DEFAULT 0, + LineTotal AS (Quantity * UnitPrice) PERSISTED, + CONSTRAINT FK_PODetail_PO FOREIGN KEY (PurchaseOrderID) REFERENCES PurchaseOrder(PurchaseOrderID), + CONSTRAINT FK_PODetail_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Production Management +-- ============================================= + +CREATE TABLE WorkCenter ( + WorkCenterID INT IDENTITY(1,1) PRIMARY KEY, + WorkCenterCode NVARCHAR(20) NOT NULL UNIQUE, + WorkCenterName NVARCHAR(100) NOT NULL, + Description NVARCHAR(255), + Capacity DECIMAL(18,2), -- Units per day + IsActive BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE ProductionOrder ( + ProductionOrderID INT IDENTITY(1,1) PRIMARY KEY, + WorkOrderNumber NVARCHAR(50) NOT NULL UNIQUE, + ItemID INT NOT NULL, -- What we're producing + WarehouseID INT NOT NULL, + WorkCenterID INT, + OrderQuantity DECIMAL(18,2) NOT NULL, + QuantityCompleted DECIMAL(18,2) DEFAULT 0, + QuantityScrapped DECIMAL(18,2) DEFAULT 0, + StartDate DATE, + PlannedCompletionDate DATE, + ActualCompletionDate DATE, + Status NVARCHAR(20) NOT NULL DEFAULT 'Planned', -- Planned, Released, InProgress, Completed, Cancelled + Priority INT DEFAULT 5, -- 1 = Highest, 10 = Lowest + Notes NVARCHAR(MAX), + CreatedBy NVARCHAR(100), + CreatedDate DATETIME2 DEFAULT GETDATE(), + ModifiedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_ProdOrder_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID), + CONSTRAINT FK_ProdOrder_Warehouse FOREIGN KEY (WarehouseID) REFERENCES Warehouse(WarehouseID), + CONSTRAINT FK_ProdOrder_WorkCenter FOREIGN KEY (WorkCenterID) REFERENCES WorkCenter(WorkCenterID) +); + +CREATE TABLE ProductionOrderMaterial ( + ProdOrderMaterialID INT IDENTITY(1,1) PRIMARY KEY, + ProductionOrderID INT NOT NULL, + ItemID INT NOT NULL, + RequiredQuantity DECIMAL(18,2) NOT NULL, + IssuedQuantity DECIMAL(18,2) DEFAULT 0, + CONSTRAINT FK_ProdMaterial_ProdOrder FOREIGN KEY (ProductionOrderID) REFERENCES ProductionOrder(ProductionOrderID), + CONSTRAINT FK_ProdMaterial_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +CREATE TABLE ProductionCompletion ( + CompletionID INT IDENTITY(1,1) PRIMARY KEY, + ProductionOrderID INT NOT NULL, + QuantityCompleted DECIMAL(18,2) NOT NULL, + QuantityScrapped DECIMAL(18,2) DEFAULT 0, + CompletionDate DATETIME2 DEFAULT GETDATE(), + WorkCenterID INT, + Notes NVARCHAR(MAX), + CompletedBy NVARCHAR(100), + CONSTRAINT FK_Completion_ProdOrder FOREIGN KEY (ProductionOrderID) REFERENCES ProductionOrder(ProductionOrderID), + CONSTRAINT FK_Completion_WorkCenter FOREIGN KEY (WorkCenterID) REFERENCES WorkCenter(WorkCenterID) +); + +-- ============================================= +-- Quality Control +-- ============================================= + +CREATE TABLE QualityInspection ( + InspectionID INT IDENTITY(1,1) PRIMARY KEY, + ItemID INT NOT NULL, + InspectionType NVARCHAR(50) NOT NULL, -- Incoming, In-Process, Final + ReferenceType NVARCHAR(50), -- PO, WO, etc. + ReferenceNumber NVARCHAR(50), + QuantityInspected DECIMAL(18,2) NOT NULL, + QuantityAccepted DECIMAL(18,2) NOT NULL, + QuantityRejected DECIMAL(18,2) NOT NULL, + InspectionDate DATETIME2 DEFAULT GETDATE(), + InspectedBy NVARCHAR(100), + Notes NVARCHAR(MAX), + CONSTRAINT FK_Quality_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Customer and Sales Management +-- ============================================= + +CREATE TABLE Customer ( + CustomerID INT IDENTITY(1,1) PRIMARY KEY, + CustomerCode NVARCHAR(20) NOT NULL UNIQUE, + CustomerName NVARCHAR(255) NOT NULL, + ContactName NVARCHAR(100), + Email NVARCHAR(100), + Phone NVARCHAR(20), + Address NVARCHAR(255), + City NVARCHAR(100), + State NVARCHAR(50), + ZipCode NVARCHAR(20), + Country NVARCHAR(50), + CreditLimit DECIMAL(18,2), + IsActive BIT NOT NULL DEFAULT 1, + CreatedDate DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE SalesOrder ( + SalesOrderID INT IDENTITY(1,1) PRIMARY KEY, + OrderNumber NVARCHAR(50) NOT NULL UNIQUE, + CustomerID INT NOT NULL, + WarehouseID INT NOT NULL, + OrderDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + RequestedDeliveryDate DATE, + ShipDate DATE, + Status NVARCHAR(20) NOT NULL DEFAULT 'Draft', -- Draft, Confirmed, InProduction, Shipped, Delivered, Cancelled + Subtotal DECIMAL(18,2) DEFAULT 0, + TaxAmount DECIMAL(18,2) DEFAULT 0, + ShippingAmount DECIMAL(18,2) DEFAULT 0, + TotalAmount DECIMAL(18,2) DEFAULT 0, + Notes NVARCHAR(MAX), + CreatedBy NVARCHAR(100), + CreatedDate DATETIME2 DEFAULT GETDATE(), + ModifiedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_SO_Customer FOREIGN KEY (CustomerID) REFERENCES Customer(CustomerID), + CONSTRAINT FK_SO_Warehouse FOREIGN KEY (WarehouseID) REFERENCES Warehouse(WarehouseID) +); + +CREATE TABLE SalesOrderDetail ( + SODetailID INT IDENTITY(1,1) PRIMARY KEY, + SalesOrderID INT NOT NULL, + LineNumber INT NOT NULL, + ItemID INT NOT NULL, + Quantity DECIMAL(18,2) NOT NULL, + UnitPrice DECIMAL(18,4) NOT NULL, + QuantityShipped DECIMAL(18,2) DEFAULT 0, + LineTotal AS (Quantity * UnitPrice) PERSISTED, + CONSTRAINT FK_SODetail_SO FOREIGN KEY (SalesOrderID) REFERENCES SalesOrder(SalesOrderID), + CONSTRAINT FK_SODetail_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Indexes for Performance +-- ============================================= + +CREATE NONCLUSTERED INDEX IX_Items_Type ON Items(ItemTypeID) INCLUDE (ItemCode, ItemName); +CREATE NONCLUSTERED INDEX IX_Items_Active ON Items(IsActive) WHERE IsActive = 1; +CREATE NONCLUSTERED INDEX IX_PO_Status ON PurchaseOrder(Status, OrderDate); +CREATE NONCLUSTERED INDEX IX_SO_Status ON SalesOrder(Status, OrderDate); +CREATE NONCLUSTERED INDEX IX_ProdOrder_Status ON ProductionOrder(Status, PlannedCompletionDate); + +GO + +PRINT 'Futon Manufacturing Database Schema created successfully!'; +GO diff --git a/samples/databases/futon-manufacturing/02-sample-data.sql b/samples/databases/futon-manufacturing/02-sample-data.sql new file mode 100644 index 0000000000..b4883e07df --- /dev/null +++ b/samples/databases/futon-manufacturing/02-sample-data.sql @@ -0,0 +1,430 @@ +-- ============================================= +-- Futon Manufacturing Sample Data +-- ============================================= + +USE FutonManufacturing; +GO + +-- ============================================= +-- Reference Data +-- ============================================= + +-- Unit of Measure +INSERT INTO UnitOfMeasure (UnitCode, UnitName, Description) VALUES +('EA', 'Each', 'Individual unit'), +('YD', 'Yard', 'Linear yard'), +('LB', 'Pound', 'Weight in pounds'), +('FT', 'Foot', 'Linear foot'), +('PC', 'Piece', 'Piece'), +('SET', 'Set', 'Set of items'), +('BOX', 'Box', 'Box'), +('ROLL', 'Roll', 'Roll of material'); + +-- Item Types +INSERT INTO ItemType (TypeCode, TypeName, Description) VALUES +('RAW', 'Raw Material', 'Raw materials purchased from suppliers'), +('COMP', 'Component', 'Manufactured components used in assemblies'), +('FG', 'Finished Goods', 'Finished products ready for sale'); + +-- Transaction Types +INSERT INTO TransactionType (TypeCode, TypeName, Description) VALUES +('PO-RCV', 'Purchase Order Receipt', 'Receipt of purchased materials'), +('PO-RET', 'Purchase Order Return', 'Return to supplier'), +('WO-ISS', 'Work Order Issue', 'Material issued to production'), +('WO-CMP', 'Work Order Completion', 'Production completion'), +('SO-SHP', 'Sales Order Shipment', 'Shipment to customer'), +('SO-RET', 'Sales Order Return', 'Customer return'), +('ADJ-POS', 'Positive Adjustment', 'Inventory increase adjustment'), +('ADJ-NEG', 'Negative Adjustment', 'Inventory decrease adjustment'), +('CYC-CNT', 'Cycle Count', 'Cycle count adjustment'); + +-- ============================================= +-- Items Master Data +-- ============================================= + +DECLARE @RawMaterialType INT = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'RAW'); +DECLARE @ComponentType INT = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'COMP'); +DECLARE @FinishedGoodType INT = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'FG'); +DECLARE @EachUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'EA'); +DECLARE @YardUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'YD'); +DECLARE @PoundUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'LB'); +DECLARE @FootUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'FT'); + +-- Raw Materials: Fill Materials +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('RM-FILL-001', 'Premium Polyester Fiber Fill', @RawMaterialType, @PoundUnit, 'High-quality polyester fiber for pillow filling', 3.50, 0, 500, 250, 14), +('RM-FILL-002', 'Memory Foam Chips', @RawMaterialType, @PoundUnit, 'Shredded memory foam for premium comfort', 8.75, 0, 300, 150, 21), +('RM-FILL-003', 'Cotton Fill', @RawMaterialType, @PoundUnit, 'Natural cotton fiber fill', 6.25, 0, 400, 200, 14), +('RM-FILL-004', 'Latex Foam Chips', @RawMaterialType, @PoundUnit, 'Natural latex foam pieces', 12.50, 0, 200, 100, 28), +('RM-FILL-005', 'Down Alternative Fill', @RawMaterialType, @PoundUnit, 'Hypoallergenic down alternative', 5.00, 0, 350, 175, 14); + +-- Raw Materials: Fabric +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('RM-FAB-001', 'Cotton Canvas - Natural', @RawMaterialType, @YardUnit, '100% cotton canvas fabric, natural color', 8.50, 0, 200, 100, 14), +('RM-FAB-002', 'Cotton Canvas - Navy Blue', @RawMaterialType, @YardUnit, '100% cotton canvas fabric, navy blue', 8.50, 0, 200, 100, 14), +('RM-FAB-003', 'Cotton Canvas - Burgundy', @RawMaterialType, @YardUnit, '100% cotton canvas fabric, burgundy', 8.50, 0, 200, 100, 14), +('RM-FAB-004', 'Microfiber Suede - Black', @RawMaterialType, @YardUnit, 'Soft microfiber suede fabric', 12.00, 0, 150, 75, 21), +('RM-FAB-005', 'Microfiber Suede - Chocolate', @RawMaterialType, @YardUnit, 'Soft microfiber suede fabric', 12.00, 0, 150, 75, 21), +('RM-FAB-006', 'Linen Blend - Beige', @RawMaterialType, @YardUnit, 'Linen cotton blend fabric', 15.00, 0, 100, 50, 21), +('RM-FAB-007', 'Twill - Khaki', @RawMaterialType, @YardUnit, 'Durable cotton twill fabric', 9.50, 0, 180, 90, 14), +('RM-FAB-008', 'Velvet - Emerald Green', @RawMaterialType, @YardUnit, 'Luxurious velvet fabric', 18.50, 0, 80, 40, 28); + +-- Raw Materials: Frame Components +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('RM-WOOD-001', 'Pine Frame Rail 6ft', @RawMaterialType, @EachUnit, 'Solid pine wood rail, 2x4x72in', 12.00, 0, 100, 50, 14), +('RM-WOOD-002', 'Pine Frame Rail 4ft', @RawMaterialType, @EachUnit, 'Solid pine wood rail, 2x4x48in', 8.50, 0, 100, 50, 14), +('RM-WOOD-003', 'Hardwood Slat 6ft', @RawMaterialType, @EachUnit, 'Hardwood support slat, 1x4x72in', 7.50, 0, 200, 100, 14), +('RM-WOOD-004', 'Hardwood Slat 4ft', @RawMaterialType, @EachUnit, 'Hardwood support slat, 1x4x48in', 5.00, 0, 200, 100, 14), +('RM-METAL-001', 'Steel Corner Bracket', @RawMaterialType, @EachUnit, 'Heavy-duty steel corner bracket', 2.75, 0, 400, 200, 7), +('RM-METAL-002', 'Steel Hinge Mechanism', @RawMaterialType, @EachUnit, 'Folding hinge for futon frame', 15.50, 0, 150, 75, 14), +('RM-HARD-001', 'Wood Screw 3in (100 pack)', @RawMaterialType, @EachUnit, 'Box of 100 3-inch wood screws', 8.00, 0, 50, 25, 7), +('RM-HARD-002', 'Bolt and Nut Kit (50 pack)', @RawMaterialType, @EachUnit, 'Box of 50 bolt and nut sets', 12.00, 0, 50, 25, 7), +('RM-FIN-001', 'Wood Stain - Dark Walnut (Quart)', @RawMaterialType, @EachUnit, 'Dark walnut wood stain', 18.00, 0, 30, 15, 7), +('RM-FIN-002', 'Wood Stain - Natural Oak (Quart)', @RawMaterialType, @EachUnit, 'Natural oak wood stain', 18.00, 0, 30, 15, 7), +('RM-FIN-003', 'Clear Polyurethane (Quart)', @RawMaterialType, @EachUnit, 'Clear protective finish', 22.00, 0, 30, 15, 7); + +-- Components: Pillows +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('COMP-PIL-001', 'Standard Polyester Pillow', @ComponentType, @EachUnit, 'Standard pillow with polyester fill', 0, 0, 50, 25, 3), +('COMP-PIL-002', 'Premium Memory Foam Pillow', @ComponentType, @EachUnit, 'Premium pillow with memory foam', 0, 0, 40, 20, 3), +('COMP-PIL-003', 'Cotton Fill Pillow', @ComponentType, @EachUnit, 'Natural cotton filled pillow', 0, 0, 40, 20, 3), +('COMP-PIL-004', 'Latex Foam Pillow', @ComponentType, @EachUnit, 'Natural latex foam pillow', 0, 0, 30, 15, 3), +('COMP-PIL-005', 'Down Alternative Pillow', @ComponentType, @EachUnit, 'Hypoallergenic down alternative pillow', 0, 0, 45, 22, 3); + +-- Components: Mattresses +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('COMP-MAT-001', 'Twin Polyester Mattress', @ComponentType, @EachUnit, 'Twin futon mattress with polyester fill', 0, 0, 20, 10, 5), +('COMP-MAT-002', 'Full Polyester Mattress', @ComponentType, @EachUnit, 'Full futon mattress with polyester fill', 0, 0, 15, 7, 5), +('COMP-MAT-003', 'Queen Memory Foam Mattress', @ComponentType, @EachUnit, 'Queen futon mattress with memory foam', 0, 0, 12, 6, 5), +('COMP-MAT-004', 'Full Cotton Mattress', @ComponentType, @EachUnit, 'Full futon mattress with cotton fill', 0, 0, 15, 7, 5), +('COMP-MAT-005', 'Queen Cotton Mattress', @ComponentType, @EachUnit, 'Queen futon mattress with cotton fill', 0, 0, 12, 6, 5); + +-- Components: Frames +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('COMP-FRM-001', 'Twin Pine Frame - Natural Oak', @ComponentType, @EachUnit, 'Twin size pine frame, natural oak finish', 0, 0, 15, 7, 7), +('COMP-FRM-002', 'Full Pine Frame - Natural Oak', @ComponentType, @EachUnit, 'Full size pine frame, natural oak finish', 0, 0, 12, 6, 7), +('COMP-FRM-003', 'Queen Pine Frame - Natural Oak', @ComponentType, @EachUnit, 'Queen size pine frame, natural oak finish', 0, 0, 10, 5, 7), +('COMP-FRM-004', 'Full Pine Frame - Dark Walnut', @ComponentType, @EachUnit, 'Full size pine frame, dark walnut finish', 0, 0, 12, 6, 7), +('COMP-FRM-005', 'Queen Pine Frame - Dark Walnut', @ComponentType, @EachUnit, 'Queen size pine frame, dark walnut finish', 0, 0, 10, 5, 7); + +-- Finished Goods: Complete Futons +INSERT INTO Items (ItemCode, ItemName, ItemTypeID, UnitID, Description, StandardCost, ListPrice, ReorderPoint, SafetyStock, LeadTimeDays) VALUES +('FG-FUT-001', 'Twin Economy Futon - Natural Canvas', @FinishedGoodType, @EachUnit, 'Twin futon with polyester mattress, natural canvas, oak frame', 0, 299.99, 10, 5, 10), +('FG-FUT-002', 'Full Economy Futon - Navy Canvas', @FinishedGoodType, @EachUnit, 'Full futon with polyester mattress, navy canvas, oak frame', 0, 399.99, 8, 4, 10), +('FG-FUT-003', 'Full Deluxe Futon - Microfiber Black', @FinishedGoodType, @EachUnit, 'Full futon with memory foam mattress, microfiber suede, walnut frame', 0, 599.99, 6, 3, 10), +('FG-FUT-004', 'Queen Premium Futon - Velvet Emerald', @FinishedGoodType, @EachUnit, 'Queen futon with cotton mattress, velvet fabric, walnut frame', 0, 799.99, 5, 2, 10), +('FG-FUT-005', 'Full Comfort Futon - Chocolate Suede', @FinishedGoodType, @EachUnit, 'Full futon with cotton mattress, chocolate suede, oak frame', 0, 499.99, 7, 3, 10), +('FG-FUT-006', 'Queen Luxury Futon - Linen Beige', @FinishedGoodType, @EachUnit, 'Queen futon with memory foam mattress, linen blend, walnut frame', 0, 899.99, 4, 2, 10); + +GO + +-- ============================================= +-- Bill of Materials - Multi-Level +-- ============================================= + +DECLARE @EachUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'EA'); +DECLARE @YardUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'YD'); +DECLARE @PoundUnit INT = (SELECT UnitID FROM UnitOfMeasure WHERE UnitCode = 'LB'); + +-- Level 1: Pillows (Components made from raw materials) +-- Standard Polyester Pillow +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-001'), 2.5, @PoundUnit, 1, 2.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-001'), 1.2, @YardUnit, 1, 5.0); + +-- Premium Memory Foam Pillow +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-002'), 3.0, @PoundUnit, 1, 2.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-004'), 1.2, @YardUnit, 1, 5.0); + +-- Cotton Fill Pillow +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-003'), 2.8, @PoundUnit, 1, 2.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-002'), 1.2, @YardUnit, 1, 5.0); + +-- Latex Foam Pillow +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-004'), 3.5, @PoundUnit, 1, 2.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-006'), 1.2, @YardUnit, 1, 5.0); + +-- Down Alternative Pillow +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-005'), 2.7, @PoundUnit, 1, 2.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-005'), 1.2, @YardUnit, 1, 5.0); + +-- Level 1: Mattresses (Components made from raw materials) +-- Twin Polyester Mattress +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-001'), 15.0, @PoundUnit, 1, 3.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-001'), 6.5, @YardUnit, 1, 5.0); + +-- Full Polyester Mattress +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-001'), 20.0, @PoundUnit, 1, 3.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-001'), 8.5, @YardUnit, 1, 5.0); + +-- Queen Memory Foam Mattress +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-002'), 28.0, @PoundUnit, 1, 3.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-004'), 10.0, @YardUnit, 1, 5.0); + +-- Full Cotton Mattress +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-003'), 22.0, @PoundUnit, 1, 3.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-003'), 8.5, @YardUnit, 1, 5.0); + +-- Queen Cotton Mattress +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-003'), 26.0, @PoundUnit, 1, 3.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-006'), 10.0, @YardUnit, 1, 5.0); + +-- Level 1: Frames (Components made from raw materials) +-- Twin Pine Frame - Natural Oak +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-002'), 4, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-004'), 8, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 8, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 2, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 1, @EachUnit, 1, 0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-002'), 0.5, @EachUnit, 1, 10.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 0.5, @EachUnit, 1, 10.0); + +-- Full Pine Frame - Natural Oak +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), 2, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-002'), 2, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), 10, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 8, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 2, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 1, @EachUnit, 1, 0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-002'), 0.75, @EachUnit, 1, 10.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 0.75, @EachUnit, 1, 10.0); + +-- Queen Pine Frame - Natural Oak +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), 4, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), 12, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 8, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 2, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 2, @EachUnit, 1, 0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-002'), 1.0, @EachUnit, 1, 10.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 1.0, @EachUnit, 1, 10.0); + +-- Full Pine Frame - Dark Walnut +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), 2, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-002'), 2, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), 10, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 8, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 2, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 1, @EachUnit, 1, 0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-001'), 0.75, @EachUnit, 1, 10.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 0.75, @EachUnit, 1, 10.0); + +-- Queen Pine Frame - Dark Walnut +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), 4, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), 12, @EachUnit, 1, 5.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 8, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 2, @EachUnit, 1, 1.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 2, @EachUnit, 1, 0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-001'), 1.0, @EachUnit, 1, 10.0), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 1.0, @EachUnit, 1, 10.0); + +-- Level 0: Finished Goods (Made from components) +-- Twin Economy Futon - Natural Canvas +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-001'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-001'), 2, @EachUnit, 0, 1.0); + +-- Full Economy Futon - Navy Canvas +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-002'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-003'), 2, @EachUnit, 0, 1.0); + +-- Full Deluxe Futon - Microfiber Black +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-003'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-002'), 2, @EachUnit, 0, 1.0); + +-- Queen Premium Futon - Velvet Emerald +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-005'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-004'), 2, @EachUnit, 0, 1.0); + +-- Full Comfort Futon - Chocolate Suede +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-004'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-005'), 2, @EachUnit, 0, 1.0); + +-- Queen Luxury Futon - Linen Beige +INSERT INTO BillOfMaterials (ParentItemID, ComponentItemID, Quantity, UnitID, BOMLevel, ScrapRate) VALUES +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-003'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), 1, @EachUnit, 0, 0.5), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), (SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-002'), 2, @EachUnit, 0, 1.0); + +GO + +-- Update Standard Costs based on BOM +UPDATE Items +SET StandardCost = ( + SELECT ISNULL(SUM(c.StandardCost * b.Quantity * (1 + b.ScrapRate/100)), 0) + FROM BillOfMaterials b + INNER JOIN Items c ON b.ComponentItemID = c.ItemID + WHERE b.ParentItemID = Items.ItemID +) +WHERE ItemTypeID IN (SELECT ItemTypeID FROM ItemType WHERE TypeCode IN ('COMP', 'FG')); + +GO + +-- ============================================= +-- Warehouses +-- ============================================= + +INSERT INTO Warehouse (WarehouseCode, WarehouseName, Address, City, State, ZipCode) VALUES +('WH-MAIN', 'Main Manufacturing Facility', '1200 Industrial Parkway', 'Portland', 'OR', '97201'), +('WH-WEST', 'West Coast Distribution', '450 Commerce Drive', 'Los Angeles', 'CA', '90001'), +('WH-EAST', 'East Coast Distribution', '780 Logistics Boulevard', 'Charlotte', 'NC', '28201'); + +-- ============================================= +-- Work Centers +-- ============================================= + +INSERT INTO WorkCenter (WorkCenterCode, WorkCenterName, Description, Capacity) VALUES +('WC-SEW', 'Sewing Department', 'Pillow and mattress cover sewing', 50), +('WC-FILL', 'Filling Station', 'Fill pillows and mattresses', 60), +('WC-WOOD', 'Woodworking Shop', 'Frame construction and finishing', 30), +('WC-ASSY', 'Final Assembly', 'Futon final assembly and packaging', 40), +('WC-QC', 'Quality Control', 'Final inspection and testing', 50); + +-- ============================================= +-- Suppliers +-- ============================================= + +INSERT INTO Supplier (SupplierCode, SupplierName, ContactName, Email, Phone, Address, City, State, ZipCode, Country, PaymentTerms, Rating) VALUES +('SUP-001', 'Pacific Textile Mills', 'Sarah Johnson', 'sarah.j@pacifictextile.com', '503-555-0101', '500 Mill Street', 'Portland', 'OR', '97202', 'USA', 'Net 30', 4.5), +('SUP-002', 'Premium Fill Supply Co', 'Michael Chen', 'mchen@premiumfill.com', '206-555-0202', '1800 Manufacturing Way', 'Seattle', 'WA', '98101', 'USA', 'Net 30', 4.8), +('SUP-003', 'Northwest Lumber & Hardware', 'David Brown', 'dbrown@nwlumber.com', '503-555-0303', '2500 Timber Road', 'Eugene', 'OR', '97401', 'USA', 'Net 45', 4.3), +('SUP-004', 'Industrial Fasteners Inc', 'Lisa Martinez', 'lmartinez@indfasteners.com', '425-555-0404', '300 Industry Blvd', 'Tacoma', 'WA', '98402', 'USA', 'Net 30', 4.6), +('SUP-005', 'Luxury Fabric Imports', 'James Wilson', 'jwilson@luxuryfabric.com', '415-555-0505', '1500 Fashion Avenue', 'San Francisco', 'CA', '94102', 'USA', 'Net 60', 4.7); + +-- Supplier Items +INSERT INTO SupplierItem (SupplierID, ItemID, SupplierPartNumber, UnitPrice, MinimumOrderQuantity, LeadTimeDays, IsPreferred) VALUES +-- Pacific Textile Mills - Fabrics +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-001'), 'PTM-CAN-NAT-001', 7.50, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-002'), 'PTM-CAN-NVY-001', 7.50, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-003'), 'PTM-CAN-BUR-001', 7.50, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-001'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-007'), 'PTM-TWL-KHA-001', 8.75, 100, 14, 1), +-- Premium Fill Supply - Fill materials +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-001'), 'PFS-POLY-001', 3.25, 500, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-002'), 'PFS-MEMF-001', 8.00, 300, 21, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-003'), 'PFS-COTN-001', 5.75, 400, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-004'), 'PFS-LATX-001', 11.50, 200, 28, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-002'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-005'), 'PFS-DOWN-001', 4.50, 350, 14, 1), +-- Northwest Lumber - Wood +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), 'NWL-PINE-6FT', 11.00, 50, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-002'), 'NWL-PINE-4FT', 7.75, 50, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), 'NWL-SLAT-6FT', 6.90, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-004'), 'NWL-SLAT-4FT', 4.60, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-001'), 'NWL-STN-WAL', 16.50, 12, 7, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-002'), 'NWL-STN-OAK', 16.50, 12, 7, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-003'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), 'NWL-FIN-CLR', 20.00, 12, 7, 1), +-- Industrial Fasteners - Hardware +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), 'IFI-BRK-001', 2.50, 200, 7, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), 'IFI-HNG-001', 14.25, 100, 14, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), 'IFI-SCR-3IN', 7.25, 50, 7, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-004'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-002'), 'IFI-BLT-KIT', 11.00, 50, 7, 1), +-- Luxury Fabric Imports - Premium fabrics +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-004'), 'LFI-MIC-BLK', 11.00, 80, 21, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-005'), 'LFI-MIC-CHO', 11.00, 80, 21, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-006'), 'LFI-LIN-BEI', 13.75, 60, 21, 1), +((SELECT SupplierID FROM Supplier WHERE SupplierCode = 'SUP-005'), (SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-008'), 'LFI-VEL-EMR', 17.00, 50, 28, 1); + +GO + +-- ============================================= +-- Initial Inventory +-- ============================================= + +DECLARE @MainWH INT = (SELECT WarehouseID FROM Warehouse WHERE WarehouseCode = 'WH-MAIN'); + +-- Raw Materials Inventory +INSERT INTO Inventory (ItemID, WarehouseID, QuantityOnHand, LastCountDate) VALUES +-- Fill materials +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-001'), @MainWH, 1500.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-002'), @MainWH, 800.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-003'), @MainWH, 1200.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-004'), @MainWH, 450.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FILL-005'), @MainWH, 900.00, '2024-11-01'), +-- Fabrics +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-001'), @MainWH, 500.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-002'), @MainWH, 450.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-003'), @MainWH, 380.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-004'), @MainWH, 300.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-005'), @MainWH, 320.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-006'), @MainWH, 180.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-007'), @MainWH, 400.00, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FAB-008'), @MainWH, 150.00, '2024-11-01'), +-- Wood +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-001'), @MainWH, 250, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-002'), @MainWH, 300, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-003'), @MainWH, 500, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-WOOD-004'), @MainWH, 600, '2024-11-01'), +-- Metal & Hardware +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-001'), @MainWH, 800, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-METAL-002'), @MainWH, 200, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-001'), @MainWH, 100, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-HARD-002'), @MainWH, 80, '2024-11-01'), +-- Finishes +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-001'), @MainWH, 45, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-002'), @MainWH, 50, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'RM-FIN-003'), @MainWH, 55, '2024-11-01'), +-- Components +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-001'), @MainWH, 120, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-002'), @MainWH, 85, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-003'), @MainWH, 95, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-004'), @MainWH, 60, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-PIL-005'), @MainWH, 110, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-001'), @MainWH, 45, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-002'), @MainWH, 38, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-003'), @MainWH, 25, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-004'), @MainWH, 32, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-MAT-005'), @MainWH, 28, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-001'), @MainWH, 35, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-002'), @MainWH, 30, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-003'), @MainWH, 25, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-004'), @MainWH, 28, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'COMP-FRM-005'), @MainWH, 22, '2024-11-01'), +-- Finished Goods +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), @MainWH, 18, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), @MainWH, 15, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), @MainWH, 12, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), @MainWH, 8, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), @MainWH, 14, '2024-11-01'), +((SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), @MainWH, 7, '2024-11-01'); + +GO + +-- ============================================= +-- Customers +-- ============================================= + +INSERT INTO Customer (CustomerCode, CustomerName, ContactName, Email, Phone, Address, City, State, ZipCode, Country, CreditLimit) VALUES +('CUST-001', 'Home Comfort Retailers', 'Jennifer Adams', 'jadams@homecomfort.com', '503-555-1001', '450 Retail Plaza', 'Portland', 'OR', '97210', 'USA', 50000), +('CUST-002', 'Furniture Warehouse Direct', 'Robert Taylor', 'rtaylor@furniturewd.com', '206-555-1002', '2200 Commerce Street', 'Seattle', 'WA', '98115', 'USA', 75000), +('CUST-003', 'Coastal Living Stores', 'Maria Garcia', 'mgarcia@coastalliving.com', '415-555-1003', '1800 Bay Avenue', 'San Francisco', 'CA', '94103', 'USA', 60000), +('CUST-004', 'University Dorm Supplies', 'Kevin Lee', 'klee@univdorm.com', '541-555-1004', '300 Campus Drive', 'Eugene', 'OR', '97403', 'USA', 40000), +('CUST-005', 'Modern Home Boutique', 'Amanda White', 'awhite@modernhome.com', '503-555-1005', '950 Design District', 'Portland', 'OR', '97209', 'USA', 35000), +('CUST-006', 'Budget Furniture Outlet', 'Chris Martinez', 'cmartinez@budgetfurniture.com', '360-555-1006', '500 Outlet Way', 'Vancouver', 'WA', '98660', 'USA', 45000), +('CUST-007', 'Luxury Living Inc', 'Patricia Johnson', 'pjohnson@luxuryliving.com', '425-555-1007', '1200 Elite Boulevard', 'Bellevue', 'WA', '98004', 'USA', 100000), +('CUST-008', 'College Town Furnishings', 'Daniel Kim', 'dkim@collegetown.com', '541-555-1008', '780 Student Lane', 'Corvallis', 'OR', '97330', 'USA', 30000); + +GO + +PRINT 'Sample data inserted successfully!'; +GO diff --git a/samples/databases/futon-manufacturing/03-manufacturing-reports.sql b/samples/databases/futon-manufacturing/03-manufacturing-reports.sql new file mode 100644 index 0000000000..3bc30eb18e --- /dev/null +++ b/samples/databases/futon-manufacturing/03-manufacturing-reports.sql @@ -0,0 +1,883 @@ +-- ============================================= +-- Top 20 Manufacturing Reports +-- Futon Manufacturing Database +-- ============================================= + +USE FutonManufacturing; +GO + +-- ============================================= +-- REPORT 1: Multi-Level BOM Explosion +-- Shows complete material requirements for any item +-- ============================================= + +CREATE OR ALTER VIEW vw_BOMExplosion AS +WITH BOMRecursive AS ( + -- Anchor: Top level + SELECT + b.ParentItemID, + p.ItemCode AS ParentItemCode, + p.ItemName AS ParentItemName, + b.ComponentItemID, + c.ItemCode AS ComponentItemCode, + c.ItemName AS ComponentItemName, + c.ItemTypeID, + t.TypeName AS ComponentType, + CAST(b.Quantity AS DECIMAL(18,4)) AS Quantity, + b.UnitID, + u.UnitCode, + b.ScrapRate, + b.BOMLevel, + CAST(b.Quantity * (1 + b.ScrapRate/100) AS DECIMAL(18,4)) AS EffectiveQuantity, + c.StandardCost, + CAST(b.Quantity * (1 + b.ScrapRate/100) * c.StandardCost AS DECIMAL(18,4)) AS ExtendedCost, + 1 AS Level, + CAST(p.ItemCode + ' > ' + c.ItemCode AS NVARCHAR(MAX)) AS BOMPath + FROM BillOfMaterials b + INNER JOIN Items p ON b.ParentItemID = p.ItemID + INNER JOIN Items c ON b.ComponentItemID = c.ItemID + INNER JOIN ItemType t ON c.ItemTypeID = t.ItemTypeID + INNER JOIN UnitOfMeasure u ON b.UnitID = u.UnitID + WHERE b.IsActive = 1 + + UNION ALL + + -- Recursive: Get sub-components + SELECT + br.ParentItemID, + br.ParentItemCode, + br.ParentItemName, + b.ComponentItemID, + c.ItemCode, + c.ItemName, + c.ItemTypeID, + t.TypeName, + CAST(br.EffectiveQuantity * b.Quantity AS DECIMAL(18,4)) AS Quantity, + b.UnitID, + u.UnitCode, + b.ScrapRate, + b.BOMLevel, + CAST(br.EffectiveQuantity * b.Quantity * (1 + b.ScrapRate/100) AS DECIMAL(18,4)) AS EffectiveQuantity, + c.StandardCost, + CAST(br.EffectiveQuantity * b.Quantity * (1 + b.ScrapRate/100) * c.StandardCost AS DECIMAL(18,4)) AS ExtendedCost, + br.Level + 1, + CAST(br.BOMPath + ' > ' + c.ItemCode AS NVARCHAR(MAX)) + FROM BOMRecursive br + INNER JOIN BillOfMaterials b ON br.ComponentItemID = b.ParentItemID + INNER JOIN Items c ON b.ComponentItemID = c.ItemID + INNER JOIN ItemType t ON c.ItemTypeID = t.ItemTypeID + INNER JOIN UnitOfMeasure u ON b.UnitID = u.UnitID + WHERE b.IsActive = 1 +) +SELECT + ParentItemID, + ParentItemCode, + ParentItemName, + ComponentItemID, + ComponentItemCode, + ComponentItemName, + ComponentType, + Level, + EffectiveQuantity, + UnitCode, + StandardCost, + ExtendedCost, + BOMPath +FROM BOMRecursive; +GO + +-- ============================================= +-- REPORT 2: Where-Used Report +-- Shows where each component is used +-- ============================================= + +CREATE OR ALTER VIEW vw_WhereUsed AS +WITH WhereUsedRecursive AS ( + -- Direct usage + SELECT + b.ComponentItemID, + c.ItemCode AS ComponentItemCode, + c.ItemName AS ComponentItemName, + b.ParentItemID, + p.ItemCode AS ParentItemCode, + p.ItemName AS ParentItemName, + t.TypeName AS ParentType, + CAST(b.Quantity AS DECIMAL(18,4)) AS Quantity, + u.UnitCode, + 1 AS Level, + CAST(c.ItemCode + ' used in ' + p.ItemCode AS NVARCHAR(MAX)) AS UsagePath + FROM BillOfMaterials b + INNER JOIN Items c ON b.ComponentItemID = c.ItemID + INNER JOIN Items p ON b.ParentItemID = p.ItemID + INNER JOIN ItemType t ON p.ItemTypeID = t.ItemTypeID + INNER JOIN UnitOfMeasure u ON b.UnitID = u.UnitID + WHERE b.IsActive = 1 + + UNION ALL + + -- Recursive usage + SELECT + wu.ComponentItemID, + wu.ComponentItemCode, + wu.ComponentItemName, + b.ParentItemID, + p.ItemCode, + p.ItemName, + t.TypeName, + CAST(wu.Quantity * b.Quantity AS DECIMAL(18,4)) AS Quantity, + u.UnitCode, + wu.Level + 1, + CAST(wu.UsagePath + ' > ' + p.ItemCode AS NVARCHAR(MAX)) + FROM WhereUsedRecursive wu + INNER JOIN BillOfMaterials b ON wu.ParentItemID = b.ComponentItemID + INNER JOIN Items p ON b.ParentItemID = p.ItemID + INNER JOIN ItemType t ON p.ItemTypeID = t.ItemTypeID + INNER JOIN UnitOfMeasure u ON b.UnitID = u.UnitID + WHERE b.IsActive = 1 +) +SELECT + ComponentItemID, + ComponentItemCode, + ComponentItemName, + ParentItemID, + ParentItemCode, + ParentItemName, + ParentType, + Level, + Quantity, + UnitCode, + UsagePath +FROM WhereUsedRecursive; +GO + +-- ============================================= +-- REPORT 3: Inventory Valuation Report +-- ============================================= + +CREATE OR ALTER VIEW vw_InventoryValuation AS +SELECT + w.WarehouseCode, + w.WarehouseName, + t.TypeName AS ItemType, + i.ItemCode, + i.ItemName, + inv.QuantityOnHand, + inv.QuantityAllocated, + inv.QuantityAvailable, + u.UnitCode, + i.StandardCost, + inv.QuantityOnHand * i.StandardCost AS InventoryValue, + inv.QuantityAvailable * i.StandardCost AS AvailableValue, + inv.LastCountDate, + DATEDIFF(DAY, inv.LastCountDate, GETDATE()) AS DaysSinceCount +FROM Inventory inv +INNER JOIN Items i ON inv.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +INNER JOIN UnitOfMeasure u ON i.UnitID = u.UnitID +INNER JOIN Warehouse w ON inv.WarehouseID = w.WarehouseID +WHERE i.IsActive = 1; +GO + +-- ============================================= +-- REPORT 4: Items Below Reorder Point +-- ============================================= + +CREATE OR ALTER VIEW vw_ItemsBelowReorderPoint AS +SELECT + w.WarehouseCode, + w.WarehouseName, + t.TypeName AS ItemType, + i.ItemCode, + i.ItemName, + inv.QuantityAvailable, + i.ReorderPoint, + i.SafetyStock, + i.ReorderPoint - inv.QuantityAvailable AS ShortageQuantity, + u.UnitCode, + i.LeadTimeDays, + s.SupplierName AS PreferredSupplier, + si.UnitPrice AS PreferredPrice, + DATEADD(DAY, i.LeadTimeDays, GETDATE()) AS ExpectedArrival +FROM Inventory inv +INNER JOIN Items i ON inv.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +INNER JOIN UnitOfMeasure u ON i.UnitID = u.UnitID +INNER JOIN Warehouse w ON inv.WarehouseID = w.WarehouseID +LEFT JOIN SupplierItem si ON i.ItemID = si.ItemID AND si.IsPreferred = 1 +LEFT JOIN Supplier s ON si.SupplierID = s.SupplierID +WHERE i.IsActive = 1 + AND inv.QuantityAvailable < i.ReorderPoint; +GO + +-- ============================================= +-- REPORT 5: Production Order Status Report +-- ============================================= + +CREATE OR ALTER VIEW vw_ProductionOrderStatus AS +SELECT + po.WorkOrderNumber, + po.Status, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + po.OrderQuantity, + po.QuantityCompleted, + po.QuantityScrapped, + po.OrderQuantity - po.QuantityCompleted - po.QuantityScrapped AS QuantityRemaining, + CAST((po.QuantityCompleted * 100.0 / NULLIF(po.OrderQuantity, 0)) AS DECIMAL(5,2)) AS PercentComplete, + wc.WorkCenterName, + w.WarehouseName, + po.StartDate, + po.PlannedCompletionDate, + po.ActualCompletionDate, + CASE + WHEN po.ActualCompletionDate IS NOT NULL THEN + DATEDIFF(DAY, po.PlannedCompletionDate, po.ActualCompletionDate) + ELSE + DATEDIFF(DAY, po.PlannedCompletionDate, GETDATE()) + END AS DaysVariance, + CASE + WHEN po.Status = 'Completed' THEN 'On Time' + WHEN GETDATE() > po.PlannedCompletionDate THEN 'Late' + WHEN DATEDIFF(DAY, GETDATE(), po.PlannedCompletionDate) <= 2 THEN 'At Risk' + ELSE 'On Track' + END AS ScheduleStatus, + po.Priority, + po.CreatedBy, + po.CreatedDate +FROM ProductionOrder po +INNER JOIN Items i ON po.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +INNER JOIN Warehouse w ON po.WarehouseID = w.WarehouseID +LEFT JOIN WorkCenter wc ON po.WorkCenterID = wc.WorkCenterID; +GO + +-- ============================================= +-- REPORT 6: Material Requirements Planning (MRP) +-- ============================================= + +CREATE OR ALTER VIEW vw_MaterialRequirements AS +WITH RequiredMaterials AS ( + SELECT + po.WorkOrderNumber, + po.Status, + po.PlannedCompletionDate, + i.ItemID, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + SUM(bom.EffectiveQuantity * (po.OrderQuantity - po.QuantityCompleted)) AS RequiredQuantity, + u.UnitCode + FROM ProductionOrder po + INNER JOIN vw_BOMExplosion bom ON po.ItemID = bom.ParentItemID + INNER JOIN Items i ON bom.ComponentItemID = i.ItemID + INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID + INNER JOIN UnitOfMeasure u ON i.UnitID = u.UnitID + WHERE po.Status IN ('Planned', 'Released', 'InProgress') + GROUP BY + po.WorkOrderNumber, po.Status, po.PlannedCompletionDate, + i.ItemID, i.ItemCode, i.ItemName, t.TypeName, u.UnitCode +) +SELECT + rm.WorkOrderNumber, + rm.Status, + rm.PlannedCompletionDate, + rm.ItemCode, + rm.ItemName, + rm.ItemType, + rm.RequiredQuantity, + ISNULL(inv.QuantityAvailable, 0) AS AvailableQuantity, + rm.RequiredQuantity - ISNULL(inv.QuantityAvailable, 0) AS ShortageQuantity, + CASE + WHEN ISNULL(inv.QuantityAvailable, 0) >= rm.RequiredQuantity THEN 'Sufficient' + WHEN ISNULL(inv.QuantityAvailable, 0) > 0 THEN 'Partial' + ELSE 'Out of Stock' + END AS AvailabilityStatus, + rm.UnitCode +FROM RequiredMaterials rm +LEFT JOIN ( + SELECT ItemID, SUM(QuantityAvailable) AS QuantityAvailable + FROM Inventory + GROUP BY ItemID +) inv ON rm.ItemID = inv.ItemID; +GO + +-- ============================================= +-- REPORT 7: Work Center Capacity Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_WorkCenterCapacity AS +SELECT + wc.WorkCenterCode, + wc.WorkCenterName, + wc.Capacity AS DailyCapacity, + COUNT(DISTINCT po.ProductionOrderID) AS ActiveOrders, + SUM(CASE WHEN po.Status = 'InProgress' THEN 1 ELSE 0 END) AS InProgressOrders, + SUM(po.OrderQuantity - po.QuantityCompleted) AS TotalQuantityPending, + CAST(SUM(po.OrderQuantity - po.QuantityCompleted) / NULLIF(wc.Capacity, 0) AS DECIMAL(10,2)) AS DaysOfWork, + CAST((COUNT(DISTINCT po.ProductionOrderID) * 100.0 / + NULLIF((SELECT COUNT(*) FROM ProductionOrder WHERE Status IN ('Planned', 'Released', 'InProgress')), 0)) + AS DECIMAL(5,2)) AS PercentOfTotalOrders, + MIN(po.PlannedCompletionDate) AS EarliestDueDate, + MAX(po.PlannedCompletionDate) AS LatestDueDate +FROM WorkCenter wc +LEFT JOIN ProductionOrder po ON wc.WorkCenterID = po.WorkCenterID + AND po.Status IN ('Planned', 'Released', 'InProgress') +WHERE wc.IsActive = 1 +GROUP BY wc.WorkCenterCode, wc.WorkCenterName, wc.Capacity; +GO + +-- ============================================= +-- REPORT 8: Production Completion Summary +-- ============================================= + +CREATE OR ALTER VIEW vw_ProductionCompletionSummary AS +SELECT + CAST(pc.CompletionDate AS DATE) AS CompletionDate, + DATEPART(YEAR, pc.CompletionDate) AS Year, + DATEPART(MONTH, pc.CompletionDate) AS Month, + DATEPART(WEEK, pc.CompletionDate) AS Week, + wc.WorkCenterName, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + COUNT(DISTINCT pc.CompletionID) AS NumberOfCompletions, + SUM(pc.QuantityCompleted) AS TotalCompleted, + SUM(pc.QuantityScrapped) AS TotalScrapped, + CAST((SUM(pc.QuantityScrapped) * 100.0 / NULLIF(SUM(pc.QuantityCompleted + pc.QuantityScrapped), 0)) + AS DECIMAL(5,2)) AS ScrapRate, + SUM(pc.QuantityCompleted * i.StandardCost) AS ProductionValue +FROM ProductionCompletion pc +INNER JOIN ProductionOrder po ON pc.ProductionOrderID = po.ProductionOrderID +INNER JOIN Items i ON po.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +LEFT JOIN WorkCenter wc ON pc.WorkCenterID = wc.WorkCenterID +GROUP BY + CAST(pc.CompletionDate AS DATE), + DATEPART(YEAR, pc.CompletionDate), + DATEPART(MONTH, pc.CompletionDate), + DATEPART(WEEK, pc.CompletionDate), + wc.WorkCenterName, + i.ItemCode, + i.ItemName, + t.TypeName; +GO + +-- ============================================= +-- REPORT 9: Quality Inspection Summary +-- ============================================= + +CREATE OR ALTER VIEW vw_QualityInspectionSummary AS +SELECT + CAST(qi.InspectionDate AS DATE) AS InspectionDate, + DATEPART(YEAR, qi.InspectionDate) AS Year, + DATEPART(MONTH, qi.InspectionDate) AS Month, + qi.InspectionType, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + COUNT(qi.InspectionID) AS NumberOfInspections, + SUM(qi.QuantityInspected) AS TotalInspected, + SUM(qi.QuantityAccepted) AS TotalAccepted, + SUM(qi.QuantityRejected) AS TotalRejected, + CAST((SUM(qi.QuantityAccepted) * 100.0 / NULLIF(SUM(qi.QuantityInspected), 0)) + AS DECIMAL(5,2)) AS AcceptanceRate, + CAST((SUM(qi.QuantityRejected) * 100.0 / NULLIF(SUM(qi.QuantityInspected), 0)) + AS DECIMAL(5,2)) AS RejectionRate +FROM QualityInspection qi +INNER JOIN Items i ON qi.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +GROUP BY + CAST(qi.InspectionDate AS DATE), + DATEPART(YEAR, qi.InspectionDate), + DATEPART(MONTH, qi.InspectionDate), + qi.InspectionType, + i.ItemCode, + i.ItemName, + t.TypeName; +GO + +-- ============================================= +-- REPORT 10: Supplier Performance Report +-- ============================================= + +CREATE OR ALTER VIEW vw_SupplierPerformance AS +WITH SupplierMetrics AS ( + SELECT + s.SupplierID, + s.SupplierCode, + s.SupplierName, + s.Rating, + COUNT(DISTINCT po.PurchaseOrderID) AS TotalOrders, + SUM(po.TotalAmount) AS TotalPurchaseValue, + AVG(DATEDIFF(DAY, po.OrderDate, po.ActualDeliveryDate)) AS AvgDeliveryDays, + SUM(CASE WHEN po.ActualDeliveryDate <= po.ExpectedDeliveryDate THEN 1 ELSE 0 END) AS OnTimeDeliveries, + COUNT(CASE WHEN po.ActualDeliveryDate IS NOT NULL THEN 1 END) AS CompletedDeliveries + FROM Supplier s + LEFT JOIN PurchaseOrder po ON s.SupplierID = po.SupplierID + WHERE s.IsActive = 1 + GROUP BY s.SupplierID, s.SupplierCode, s.SupplierName, s.Rating +) +SELECT + SupplierCode, + SupplierName, + Rating AS SupplierRating, + TotalOrders, + TotalPurchaseValue, + AvgDeliveryDays, + OnTimeDeliveries, + CompletedDeliveries, + CAST((OnTimeDeliveries * 100.0 / NULLIF(CompletedDeliveries, 0)) AS DECIMAL(5,2)) AS OnTimeDeliveryRate, + CASE + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(CompletedDeliveries, 0)) AS DECIMAL(5,2)) >= 95 THEN 'Excellent' + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(CompletedDeliveries, 0)) AS DECIMAL(5,2)) >= 85 THEN 'Good' + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(CompletedDeliveries, 0)) AS DECIMAL(5,2)) >= 75 THEN 'Fair' + ELSE 'Poor' + END AS PerformanceGrade +FROM SupplierMetrics; +GO + +-- ============================================= +-- REPORT 11: Purchase Order Status +-- ============================================= + +CREATE OR ALTER VIEW vw_PurchaseOrderStatus AS +SELECT + po.PONumber, + po.Status, + s.SupplierName, + w.WarehouseName, + po.OrderDate, + po.ExpectedDeliveryDate, + po.ActualDeliveryDate, + DATEDIFF(DAY, po.OrderDate, ISNULL(po.ActualDeliveryDate, GETDATE())) AS DaysSinceOrder, + CASE + WHEN po.ActualDeliveryDate IS NOT NULL THEN + DATEDIFF(DAY, po.ExpectedDeliveryDate, po.ActualDeliveryDate) + ELSE + DATEDIFF(DAY, po.ExpectedDeliveryDate, GETDATE()) + END AS DaysVariance, + COUNT(DISTINCT pod.PODetailID) AS LineItems, + po.TotalAmount, + SUM(pod.LineTotal) AS LinesTotal, + SUM(pod.QuantityReceived * pod.UnitPrice) AS ReceivedValue, + CAST((SUM(pod.QuantityReceived) * 100.0 / NULLIF(SUM(pod.Quantity), 0)) + AS DECIMAL(5,2)) AS PercentReceived, + CASE + WHEN po.Status = 'Received' THEN 'Complete' + WHEN po.Status = 'Cancelled' THEN 'Cancelled' + WHEN GETDATE() > po.ExpectedDeliveryDate AND po.Status NOT IN ('Received', 'Cancelled') THEN 'Overdue' + WHEN DATEDIFF(DAY, GETDATE(), po.ExpectedDeliveryDate) <= 3 THEN 'Due Soon' + ELSE 'On Track' + END AS DeliveryStatus +FROM PurchaseOrder po +INNER JOIN Supplier s ON po.SupplierID = s.SupplierID +INNER JOIN Warehouse w ON po.WarehouseID = w.WarehouseID +LEFT JOIN PurchaseOrderDetail pod ON po.PurchaseOrderID = pod.PurchaseOrderID +GROUP BY + po.PONumber, po.Status, s.SupplierName, w.WarehouseName, + po.OrderDate, po.ExpectedDeliveryDate, po.ActualDeliveryDate, + po.TotalAmount, po.PurchaseOrderID; +GO + +-- ============================================= +-- REPORT 12: Sales Order Backlog +-- ============================================= + +CREATE OR ALTER VIEW vw_SalesOrderBacklog AS +SELECT + so.OrderNumber, + so.Status, + c.CustomerName, + c.CustomerCode, + w.WarehouseName, + so.OrderDate, + so.RequestedDeliveryDate, + so.ShipDate, + DATEDIFF(DAY, so.OrderDate, GETDATE()) AS DaysOpen, + DATEDIFF(DAY, GETDATE(), so.RequestedDeliveryDate) AS DaysUntilDue, + COUNT(DISTINCT sod.SODetailID) AS LineItems, + SUM(sod.Quantity) AS TotalQuantityOrdered, + SUM(sod.QuantityShipped) AS TotalQuantityShipped, + SUM(sod.Quantity - sod.QuantityShipped) AS QuantityBacklog, + so.TotalAmount, + SUM(sod.LineTotal) AS OrderValue, + SUM((sod.Quantity - sod.QuantityShipped) * sod.UnitPrice) AS BacklogValue, + CAST((SUM(sod.QuantityShipped) * 100.0 / NULLIF(SUM(sod.Quantity), 0)) + AS DECIMAL(5,2)) AS PercentComplete, + CASE + WHEN so.Status = 'Delivered' THEN 'Complete' + WHEN so.Status = 'Cancelled' THEN 'Cancelled' + WHEN GETDATE() > so.RequestedDeliveryDate AND so.Status NOT IN ('Delivered', 'Shipped') THEN 'Overdue' + WHEN DATEDIFF(DAY, GETDATE(), so.RequestedDeliveryDate) <= 5 THEN 'Due Soon' + ELSE 'On Track' + END AS FulfillmentStatus +FROM SalesOrder so +INNER JOIN Customer c ON so.CustomerID = c.CustomerID +INNER JOIN Warehouse w ON so.WarehouseID = w.WarehouseID +LEFT JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID +WHERE so.Status NOT IN ('Delivered', 'Cancelled') +GROUP BY + so.OrderNumber, so.Status, c.CustomerName, c.CustomerCode, w.WarehouseName, + so.OrderDate, so.RequestedDeliveryDate, so.ShipDate, so.TotalAmount; +GO + +-- ============================================= +-- REPORT 13: Cost Roll-Up by Item +-- ============================================= + +CREATE OR ALTER VIEW vw_CostRollUp AS +WITH ItemCosts AS ( + SELECT + ParentItemID, + ParentItemCode, + ParentItemName, + SUM(ExtendedCost) AS TotalMaterialCost, + COUNT(DISTINCT ComponentItemID) AS NumberOfComponents + FROM vw_BOMExplosion + GROUP BY ParentItemID, ParentItemCode, ParentItemName +) +SELECT + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + i.StandardCost AS CurrentStandardCost, + ISNULL(ic.TotalMaterialCost, 0) AS CalculatedMaterialCost, + i.StandardCost - ISNULL(ic.TotalMaterialCost, 0) AS LaborAndOverhead, + ISNULL(ic.NumberOfComponents, 0) AS ComponentCount, + i.ListPrice, + i.ListPrice - i.StandardCost AS GrossProfit, + CAST(((i.ListPrice - i.StandardCost) * 100.0 / NULLIF(i.ListPrice, 0)) + AS DECIMAL(5,2)) AS GrossMarginPercent +FROM Items i +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +LEFT JOIN ItemCosts ic ON i.ItemID = ic.ParentItemID +WHERE i.IsActive = 1; +GO + +-- ============================================= +-- REPORT 14: Inventory Turnover Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_InventoryTurnover AS +WITH TransactionSummary AS ( + SELECT + it.ItemID, + SUM(CASE WHEN it.Quantity < 0 THEN ABS(it.Quantity) ELSE 0 END) AS QuantityIssued, + SUM(CASE WHEN it.Quantity > 0 THEN it.Quantity ELSE 0 END) AS QuantityReceived, + COUNT(*) AS TransactionCount, + MIN(it.TransactionDate) AS FirstTransaction, + MAX(it.TransactionDate) AS LastTransaction + FROM InventoryTransaction it + WHERE it.TransactionDate >= DATEADD(MONTH, -12, GETDATE()) + GROUP BY it.ItemID +) +SELECT + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + inv.QuantityOnHand, + inv.QuantityAvailable, + ts.QuantityIssued AS [Annual Usage], + ts.QuantityReceived AS AnnualReceipts, + ts.TransactionCount, + CAST(ts.QuantityIssued / NULLIF(inv.QuantityOnHand, 0) AS DECIMAL(10,2)) AS TurnoverRatio, + CAST((inv.QuantityOnHand * 365.0) / NULLIF(ts.QuantityIssued, 0) AS DECIMAL(10,1)) AS DaysOnHand, + inv.QuantityOnHand * i.StandardCost AS InventoryValue, + ts.FirstTransaction, + ts.LastTransaction, + DATEDIFF(DAY, ts.LastTransaction, GETDATE()) AS DaysSinceLastActivity, + CASE + WHEN CAST(ts.QuantityIssued / NULLIF(inv.QuantityOnHand, 0) AS DECIMAL(10,2)) >= 12 THEN 'Fast Moving' + WHEN CAST(ts.QuantityIssued / NULLIF(inv.QuantityOnHand, 0) AS DECIMAL(10,2)) >= 4 THEN 'Normal' + WHEN CAST(ts.QuantityIssued / NULLIF(inv.QuantityOnHand, 0) AS DECIMAL(10,2)) >= 1 THEN 'Slow Moving' + ELSE 'Non-Moving' + END AS MovementClass +FROM Items i +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +INNER JOIN Inventory inv ON i.ItemID = inv.ItemID +LEFT JOIN TransactionSummary ts ON i.ItemID = ts.ItemID +WHERE i.IsActive = 1 AND inv.QuantityOnHand > 0; +GO + +-- ============================================= +-- REPORT 15: Late Production Orders +-- ============================================= + +CREATE OR ALTER VIEW vw_LateProductionOrders AS +SELECT + po.WorkOrderNumber, + po.Status, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + po.OrderQuantity, + po.QuantityCompleted, + po.OrderQuantity - po.QuantityCompleted AS QuantityRemaining, + wc.WorkCenterName, + po.StartDate, + po.PlannedCompletionDate, + DATEDIFF(DAY, po.PlannedCompletionDate, GETDATE()) AS DaysLate, + po.Priority, + CASE + WHEN DATEDIFF(DAY, po.PlannedCompletionDate, GETDATE()) > 10 THEN 'Critical' + WHEN DATEDIFF(DAY, po.PlannedCompletionDate, GETDATE()) > 5 THEN 'High' + WHEN DATEDIFF(DAY, po.PlannedCompletionDate, GETDATE()) > 2 THEN 'Medium' + ELSE 'Low' + END AS LatenessSeverity, + (po.OrderQuantity - po.QuantityCompleted) * i.StandardCost AS ValueAtRisk +FROM ProductionOrder po +INNER JOIN Items i ON po.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +LEFT JOIN WorkCenter wc ON po.WorkCenterID = wc.WorkCenterID +WHERE po.Status IN ('Planned', 'Released', 'InProgress') + AND po.PlannedCompletionDate < CAST(GETDATE() AS DATE); +GO + +-- ============================================= +-- REPORT 16: Component Shortage Report +-- ============================================= + +CREATE OR ALTER VIEW vw_ComponentShortage AS +WITH ProductionNeeds AS ( + SELECT + i.ItemID, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + SUM(bom.EffectiveQuantity * (po.OrderQuantity - po.QuantityCompleted)) AS RequiredQuantity, + MIN(po.PlannedCompletionDate) AS EarliestNeedDate, + COUNT(DISTINCT po.ProductionOrderID) AS AffectedOrders + FROM ProductionOrder po + INNER JOIN vw_BOMExplosion bom ON po.ItemID = bom.ParentItemID + INNER JOIN Items i ON bom.ComponentItemID = i.ItemID + INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID + WHERE po.Status IN ('Planned', 'Released', 'InProgress') + GROUP BY i.ItemID, i.ItemCode, i.ItemName, t.TypeName +) +SELECT + pn.ItemCode, + pn.ItemName, + pn.ItemType, + pn.RequiredQuantity, + ISNULL(inv.QuantityAvailable, 0) AS AvailableQuantity, + pn.RequiredQuantity - ISNULL(inv.QuantityAvailable, 0) AS ShortageQuantity, + pn.EarliestNeedDate, + DATEDIFF(DAY, GETDATE(), pn.EarliestNeedDate) AS DaysUntilNeeded, + pn.AffectedOrders, + CASE + WHEN DATEDIFF(DAY, GETDATE(), pn.EarliestNeedDate) <= 2 THEN 'Urgent' + WHEN DATEDIFF(DAY, GETDATE(), pn.EarliestNeedDate) <= 5 THEN 'High' + WHEN DATEDIFF(DAY, GETDATE(), pn.EarliestNeedDate) <= 10 THEN 'Medium' + ELSE 'Low' + END AS UrgencyLevel, + s.SupplierName AS PreferredSupplier, + si.LeadTimeDays, + DATEADD(DAY, si.LeadTimeDays, GETDATE()) AS PossibleArrival, + CASE + WHEN DATEADD(DAY, si.LeadTimeDays, GETDATE()) <= pn.EarliestNeedDate THEN 'Can Meet' + ELSE 'Will Be Late' + END AS SupplyStatus +FROM ProductionNeeds pn +LEFT JOIN ( + SELECT ItemID, SUM(QuantityAvailable) AS QuantityAvailable + FROM Inventory + GROUP BY ItemID +) inv ON pn.ItemID = inv.ItemID +LEFT JOIN SupplierItem si ON pn.ItemID = si.ItemID AND si.IsPreferred = 1 +LEFT JOIN Supplier s ON si.SupplierID = s.SupplierID +WHERE pn.RequiredQuantity > ISNULL(inv.QuantityAvailable, 0); +GO + +-- ============================================= +-- REPORT 17: Daily Production Schedule +-- ============================================= + +CREATE OR ALTER VIEW vw_DailyProductionSchedule AS +SELECT + po.PlannedCompletionDate AS ScheduledDate, + DATENAME(WEEKDAY, po.PlannedCompletionDate) AS DayOfWeek, + wc.WorkCenterName, + po.WorkOrderNumber, + po.Status, + i.ItemCode, + i.ItemName, + po.OrderQuantity - po.QuantityCompleted AS QuantityToProduce, + po.Priority, + CAST(((po.OrderQuantity - po.QuantityCompleted) / NULLIF(wc.Capacity, 0)) + AS DECIMAL(10,2)) AS EstimatedDays, + (po.OrderQuantity - po.QuantityCompleted) * i.StandardCost AS ProductionValue, + CASE + WHEN EXISTS ( + SELECT 1 FROM vw_ComponentShortage cs + INNER JOIN vw_BOMExplosion bom ON cs.ItemCode = bom.ComponentItemCode + WHERE bom.ParentItemID = po.ItemID + ) THEN 'Material Shortage' + WHEN po.PlannedCompletionDate < GETDATE() THEN 'Overdue' + WHEN po.PlannedCompletionDate = CAST(GETDATE() AS DATE) THEN 'Due Today' + ELSE 'On Schedule' + END AS ProductionStatus +FROM ProductionOrder po +INNER JOIN Items i ON po.ItemID = i.ItemID +LEFT JOIN WorkCenter wc ON po.WorkCenterID = wc.WorkCenterID +WHERE po.Status IN ('Planned', 'Released', 'InProgress') + AND po.PlannedCompletionDate BETWEEN CAST(GETDATE() AS DATE) AND DATEADD(DAY, 14, CAST(GETDATE() AS DATE)); +GO + +-- ============================================= +-- REPORT 18: Scrap and Waste Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_ScrapWasteAnalysis AS +SELECT + CAST(pc.CompletionDate AS DATE) AS CompletionDate, + DATEPART(YEAR, pc.CompletionDate) AS Year, + DATEPART(MONTH, pc.CompletionDate) AS Month, + wc.WorkCenterName, + i.ItemCode, + i.ItemName, + t.TypeName AS ItemType, + SUM(pc.QuantityCompleted) AS TotalCompleted, + SUM(pc.QuantityScrapped) AS TotalScrapped, + SUM(pc.QuantityCompleted + pc.QuantityScrapped) AS TotalProduced, + CAST((SUM(pc.QuantityScrapped) * 100.0 / + NULLIF(SUM(pc.QuantityCompleted + pc.QuantityScrapped), 0)) + AS DECIMAL(5,2)) AS ScrapRate, + SUM(pc.QuantityScrapped * i.StandardCost) AS ScrapValue, + COUNT(DISTINCT pc.ProductionOrderID) AS NumberOfOrders, + AVG(i.StandardCost) AS AvgUnitCost, + CASE + WHEN CAST((SUM(pc.QuantityScrapped) * 100.0 / + NULLIF(SUM(pc.QuantityCompleted + pc.QuantityScrapped), 0)) + AS DECIMAL(5,2)) > 10 THEN 'High' + WHEN CAST((SUM(pc.QuantityScrapped) * 100.0 / + NULLIF(SUM(pc.QuantityCompleted + pc.QuantityScrapped), 0)) + AS DECIMAL(5,2)) > 5 THEN 'Medium' + ELSE 'Low' + END AS ScrapLevel +FROM ProductionCompletion pc +INNER JOIN ProductionOrder po ON pc.ProductionOrderID = po.ProductionOrderID +INNER JOIN Items i ON po.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +LEFT JOIN WorkCenter wc ON pc.WorkCenterID = wc.WorkCenterID +GROUP BY + CAST(pc.CompletionDate AS DATE), + DATEPART(YEAR, pc.CompletionDate), + DATEPART(MONTH, pc.CompletionDate), + wc.WorkCenterName, + i.ItemCode, + i.ItemName, + t.TypeName; +GO + +-- ============================================= +-- REPORT 19: Customer Order Fulfillment Rate +-- ============================================= + +CREATE OR ALTER VIEW vw_CustomerFulfillmentRate AS +WITH CustomerMetrics AS ( + SELECT + c.CustomerID, + c.CustomerCode, + c.CustomerName, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + SUM(so.TotalAmount) AS TotalOrderValue, + SUM(CASE WHEN so.Status = 'Delivered' THEN 1 ELSE 0 END) AS DeliveredOrders, + SUM(CASE WHEN so.Status = 'Delivered' AND so.ShipDate <= so.RequestedDeliveryDate + THEN 1 ELSE 0 END) AS OnTimeDeliveries, + SUM(CASE WHEN so.Status = 'Delivered' THEN so.TotalAmount ELSE 0 END) AS DeliveredValue, + AVG(CASE WHEN so.ShipDate IS NOT NULL + THEN DATEDIFF(DAY, so.OrderDate, so.ShipDate) END) AS AvgDaysToShip, + AVG(CASE WHEN so.Status = 'Delivered' + THEN DATEDIFF(DAY, so.RequestedDeliveryDate, so.ShipDate) END) AS AvgDeliveryVariance + FROM Customer c + LEFT JOIN SalesOrder so ON c.CustomerID = so.CustomerID + WHERE c.IsActive = 1 + GROUP BY c.CustomerID, c.CustomerCode, c.CustomerName +) +SELECT + CustomerCode, + CustomerName, + TotalOrders, + TotalOrderValue, + DeliveredOrders, + OnTimeDeliveries, + DeliveredValue, + TotalOrders - DeliveredOrders AS PendingOrders, + TotalOrderValue - DeliveredValue AS PendingValue, + CAST((DeliveredOrders * 100.0 / NULLIF(TotalOrders, 0)) + AS DECIMAL(5,2)) AS FulfillmentRate, + CAST((OnTimeDeliveries * 100.0 / NULLIF(DeliveredOrders, 0)) + AS DECIMAL(5,2)) AS OnTimeDeliveryRate, + AvgDaysToShip, + AvgDeliveryVariance, + CASE + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(DeliveredOrders, 0)) AS DECIMAL(5,2)) >= 95 THEN 'Excellent' + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(DeliveredOrders, 0)) AS DECIMAL(5,2)) >= 85 THEN 'Good' + WHEN CAST((OnTimeDeliveries * 100.0 / NULLIF(DeliveredOrders, 0)) AS DECIMAL(5,2)) >= 75 THEN 'Fair' + ELSE 'Poor' + END AS ServiceLevel +FROM CustomerMetrics +WHERE TotalOrders > 0; +GO + +-- ============================================= +-- REPORT 20: Raw Material Usage by Period +-- ============================================= + +CREATE OR ALTER VIEW vw_RawMaterialUsage AS +SELECT + DATEPART(YEAR, it.TransactionDate) AS Year, + DATEPART(MONTH, it.TransactionDate) AS Month, + DATEPART(QUARTER, it.TransactionDate) AS Quarter, + t.TypeName AS ItemType, + i.ItemCode, + i.ItemName, + u.UnitCode, + SUM(CASE WHEN it.Quantity < 0 THEN ABS(it.Quantity) ELSE 0 END) AS TotalUsage, + SUM(CASE WHEN it.Quantity > 0 THEN it.Quantity ELSE 0 END) AS TotalReceipts, + COUNT(CASE WHEN it.Quantity < 0 THEN 1 END) AS NumberOfIssues, + AVG(CASE WHEN it.Quantity < 0 THEN ABS(it.Quantity) END) AS AvgIssueQuantity, + SUM(CASE WHEN it.Quantity < 0 THEN ABS(it.Quantity) * ISNULL(it.UnitCost, i.StandardCost) + ELSE 0 END) AS TotalUsageValue, + AVG(CASE WHEN it.Quantity < 0 THEN ISNULL(it.UnitCost, i.StandardCost) END) AS AvgUnitCost +FROM InventoryTransaction it +INNER JOIN Items i ON it.ItemID = i.ItemID +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +INNER JOIN UnitOfMeasure u ON i.UnitID = u.UnitID +WHERE t.TypeCode = 'RAW' + AND it.TransactionDate >= DATEADD(MONTH, -12, GETDATE()) +GROUP BY + DATEPART(YEAR, it.TransactionDate), + DATEPART(MONTH, it.TransactionDate), + DATEPART(QUARTER, it.TransactionDate), + t.TypeName, + i.ItemCode, + i.ItemName, + u.UnitCode; +GO + +PRINT 'All 20 manufacturing reports created successfully!'; +PRINT ''; +PRINT 'Available Reports:'; +PRINT '1. vw_BOMExplosion - Multi-Level BOM Explosion'; +PRINT '2. vw_WhereUsed - Where-Used Report'; +PRINT '3. vw_InventoryValuation - Inventory Valuation'; +PRINT '4. vw_ItemsBelowReorderPoint - Items Below Reorder Point'; +PRINT '5. vw_ProductionOrderStatus - Production Order Status'; +PRINT '6. vw_MaterialRequirements - Material Requirements Planning'; +PRINT '7. vw_WorkCenterCapacity - Work Center Capacity Analysis'; +PRINT '8. vw_ProductionCompletionSummary - Production Completion Summary'; +PRINT '9. vw_QualityInspectionSummary - Quality Inspection Summary'; +PRINT '10. vw_SupplierPerformance - Supplier Performance'; +PRINT '11. vw_PurchaseOrderStatus - Purchase Order Status'; +PRINT '12. vw_SalesOrderBacklog - Sales Order Backlog'; +PRINT '13. vw_CostRollUp - Cost Roll-Up by Item'; +PRINT '14. vw_InventoryTurnover - Inventory Turnover Analysis'; +PRINT '15. vw_LateProductionOrders - Late Production Orders'; +PRINT '16. vw_ComponentShortage - Component Shortage Report'; +PRINT '17. vw_DailyProductionSchedule - Daily Production Schedule'; +PRINT '18. vw_ScrapWasteAnalysis - Scrap and Waste Analysis'; +PRINT '19. vw_CustomerFulfillmentRate - Customer Order Fulfillment Rate'; +PRINT '20. vw_RawMaterialUsage - Raw Material Usage by Period'; +GO diff --git a/samples/databases/futon-manufacturing/04-sample-queries.sql b/samples/databases/futon-manufacturing/04-sample-queries.sql new file mode 100644 index 0000000000..711591d6e9 --- /dev/null +++ b/samples/databases/futon-manufacturing/04-sample-queries.sql @@ -0,0 +1,557 @@ +-- ============================================= +-- Sample Queries and Use Cases +-- Futon Manufacturing Database +-- ============================================= + +USE FutonManufacturing; +GO + +PRINT '============================================='; +PRINT 'SAMPLE QUERIES FOR FUTON MANUFACTURING DATABASE'; +PRINT '============================================='; +PRINT ''; + +-- ============================================= +-- 1. BILL OF MATERIALS QUERIES +-- ============================================= + +PRINT '1. BOM Explosion - Show all materials needed for Queen Luxury Futon'; +PRINT '---------------------------------------------------------------------'; +SELECT + REPLICATE(' ', Level - 1) + ComponentItemName AS Component, + ComponentType, + EffectiveQuantity, + UnitCode, + StandardCost AS UnitCost, + ExtendedCost, + BOMPath +FROM vw_BOMExplosion +WHERE ParentItemCode = 'FG-FUT-006' +ORDER BY Level, ComponentItemCode; +GO + +PRINT ''; +PRINT '2. Where Used - Find all products using Memory Foam'; +PRINT '---------------------------------------------------------------------'; +SELECT + ComponentItemName AS [Component], + ParentItemName AS [Used In], + ParentType, + Quantity, + UnitCode, + Level +FROM vw_WhereUsed +WHERE ComponentItemCode = 'RM-FILL-002' +ORDER BY Level, ParentItemCode; +GO + +PRINT ''; +PRINT '3. Calculate total raw material cost for each finished good'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemCode, + ItemName, + CalculatedMaterialCost AS MaterialCost, + LaborAndOverhead, + CurrentStandardCost AS TotalCost, + ListPrice, + GrossProfit, + GrossMarginPercent AS [Margin %], + ComponentCount +FROM vw_CostRollUp +WHERE ItemType = 'Finished Goods' +ORDER BY GrossMarginPercent DESC; +GO + +-- ============================================= +-- 2. INVENTORY MANAGEMENT QUERIES +-- ============================================= + +PRINT ''; +PRINT '4. Current Inventory Valuation by Type'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemType, + COUNT(DISTINCT ItemCode) AS ItemCount, + SUM(QuantityOnHand) AS TotalQuantity, + SUM(InventoryValue) AS TotalValue, + AVG(StandardCost) AS AvgUnitCost +FROM vw_InventoryValuation +GROUP BY ItemType +ORDER BY TotalValue DESC; +GO + +PRINT ''; +PRINT '5. Items Needing Reorder (Below Reorder Point)'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemType, + ItemCode, + ItemName, + QuantityAvailable AS Available, + ReorderPoint AS [Reorder Point], + ShortageQuantity AS Shortage, + PreferredSupplier AS Supplier, + PreferredPrice AS Price, + LeadTimeDays AS [Lead Days], + ExpectedArrival +FROM vw_ItemsBelowReorderPoint +ORDER BY ShortageQuantity DESC; +GO + +PRINT ''; +PRINT '6. Inventory Turnover - Identify slow moving items'; +PRINT '---------------------------------------------------------------------'; +SELECT TOP 10 + ItemCode, + ItemName, + ItemType, + QuantityOnHand AS [On Hand], + [Annual Usage], + TurnoverRatio AS [Turns/Year], + DaysOnHand AS [Days Supply], + InventoryValue AS [Inv Value], + MovementClass +FROM vw_InventoryTurnover +WHERE MovementClass IN ('Slow Moving', 'Non-Moving') +ORDER BY InventoryValue DESC; +GO + +-- ============================================= +-- 3. PRODUCTION PLANNING QUERIES +-- ============================================= + +PRINT ''; +PRINT '7. Material Requirements for Open Production Orders'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemCode, + ItemName, + ItemType, + SUM(RequiredQuantity) AS TotalRequired, + SUM(AvailableQuantity) AS TotalAvailable, + SUM(ShortageQuantity) AS TotalShortage, + COUNT(DISTINCT WorkOrderNumber) AS AffectedOrders +FROM vw_MaterialRequirements +GROUP BY ItemCode, ItemName, ItemType +HAVING SUM(ShortageQuantity) > 0 +ORDER BY SUM(ShortageQuantity) DESC; +GO + +PRINT ''; +PRINT '8. Work Center Capacity and Utilization'; +PRINT '---------------------------------------------------------------------'; +SELECT + WorkCenterName, + DailyCapacity, + ActiveOrders, + InProgressOrders, + TotalQuantityPending AS [Qty Pending], + DaysOfWork AS [Days Backlog], + PercentOfTotalOrders AS [% of Orders], + EarliestDueDate, + LatestDueDate +FROM vw_WorkCenterCapacity +ORDER BY DaysOfWork DESC; +GO + +PRINT ''; +PRINT '9. Production Schedule for Next 7 Days'; +PRINT '---------------------------------------------------------------------'; +SELECT + ScheduledDate, + DayOfWeek, + WorkCenterName, + WorkOrderNumber, + ItemName, + QuantityToProduce AS Quantity, + Priority, + ProductionStatus AS Status +FROM vw_DailyProductionSchedule +WHERE ScheduledDate BETWEEN CAST(GETDATE() AS DATE) AND DATEADD(DAY, 7, CAST(GETDATE() AS DATE)) +ORDER BY ScheduledDate, Priority, WorkCenterName; +GO + +-- ============================================= +-- 4. QUALITY AND SCRAP ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '10. Scrap Analysis - Items with High Scrap Rates'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemCode, + ItemName, + ItemType, + SUM(TotalCompleted) AS Completed, + SUM(TotalScrapped) AS Scrapped, + AVG(ScrapRate) AS [Avg Scrap %], + SUM(ScrapValue) AS [Scrap $], + ScrapLevel +FROM vw_ScrapWasteAnalysis +GROUP BY ItemCode, ItemName, ItemType, ScrapLevel +HAVING AVG(ScrapRate) > 5 +ORDER BY SUM(ScrapValue) DESC; +GO + +-- ============================================= +-- 5. SUPPLIER PERFORMANCE QUERIES +-- ============================================= + +PRINT ''; +PRINT '11. Supplier Performance Scorecard'; +PRINT '---------------------------------------------------------------------'; +SELECT + SupplierName, + SupplierRating AS Rating, + TotalOrders AS Orders, + TotalPurchaseValue AS [Purchase Value], + OnTimeDeliveryRate AS [OT Delivery %], + AvgDeliveryDays AS [Avg Days], + PerformanceGrade AS Grade +FROM vw_SupplierPerformance +ORDER BY OnTimeDeliveryRate DESC; +GO + +-- ============================================= +-- 6. SALES AND CUSTOMER QUERIES +-- ============================================= + +PRINT ''; +PRINT '12. Customer Fulfillment Performance'; +PRINT '---------------------------------------------------------------------'; +SELECT + CustomerName, + TotalOrders AS Orders, + TotalOrderValue AS [Order Value], + DeliveredOrders AS Delivered, + PendingOrders AS Pending, + FulfillmentRate AS [Fulfill %], + OnTimeDeliveryRate AS [OnTime %], + AvgDaysToShip AS [Avg Ship Days], + ServiceLevel +FROM vw_CustomerFulfillmentRate +ORDER BY TotalOrderValue DESC; +GO + +PRINT ''; +PRINT '13. Sales Order Backlog Summary'; +PRINT '---------------------------------------------------------------------'; +SELECT + FulfillmentStatus AS Status, + COUNT(*) AS OrderCount, + SUM(TotalQuantityOrdered) AS TotalUnits, + SUM(QuantityBacklog) AS BacklogUnits, + SUM(OrderValue) AS OrderValue, + SUM(BacklogValue) AS BacklogValue, + AVG(DaysOpen) AS AvgDaysOpen +FROM vw_SalesOrderBacklog +GROUP BY FulfillmentStatus +ORDER BY BacklogValue DESC; +GO + +-- ============================================= +-- 7. PRODUCTION STATUS QUERIES +-- ============================================= + +PRINT ''; +PRINT '14. Late Production Orders - Overdue Work'; +PRINT '---------------------------------------------------------------------'; +SELECT + WorkOrderNumber, + ItemName, + OrderQuantity, + QuantityRemaining, + PlannedCompletionDate, + DaysLate, + LatenessSeverity, + ValueAtRisk, + WorkCenterName +FROM vw_LateProductionOrders +ORDER BY DaysLate DESC; +GO + +PRINT ''; +PRINT '15. Component Shortages Affecting Production'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemCode, + ItemName, + RequiredQuantity, + AvailableQuantity, + ShortageQuantity, + EarliestNeedDate, + DaysUntilNeeded, + AffectedOrders, + UrgencyLevel, + PreferredSupplier, + SupplyStatus +FROM vw_ComponentShortage +WHERE UrgencyLevel IN ('Urgent', 'High') +ORDER BY DaysUntilNeeded; +GO + +-- ============================================= +-- 8. ADVANCED ANALYTICAL QUERIES +-- ============================================= + +PRINT ''; +PRINT '16. Product Profitability Analysis'; +PRINT '---------------------------------------------------------------------'; +SELECT + i.ItemCode, + i.ItemName, + i.StandardCost, + i.ListPrice, + i.ListPrice - i.StandardCost AS GrossProfit, + CAST(((i.ListPrice - i.StandardCost) * 100.0 / NULLIF(i.ListPrice, 0)) AS DECIMAL(5,2)) AS [Margin %], + ISNULL(inv.QuantityOnHand, 0) AS [Stock Level], + i.ReorderPoint, + CASE + WHEN ISNULL(inv.QuantityOnHand, 0) < i.ReorderPoint THEN 'Low Stock' + WHEN ISNULL(inv.QuantityOnHand, 0) > i.ReorderPoint * 2 THEN 'Overstock' + ELSE 'Normal' + END AS StockStatus +FROM Items i +INNER JOIN ItemType t ON i.ItemTypeID = t.ItemTypeID +LEFT JOIN ( + SELECT ItemID, SUM(QuantityOnHand) AS QuantityOnHand + FROM Inventory + GROUP BY ItemID +) inv ON i.ItemID = inv.ItemID +WHERE t.TypeCode = 'FG' AND i.IsActive = 1 +ORDER BY [Margin %] DESC; +GO + +PRINT ''; +PRINT '17. Monthly Production Trend Analysis'; +PRINT '---------------------------------------------------------------------'; +SELECT + Year, + Month, + ItemType, + COUNT(DISTINCT ItemCode) AS Products, + SUM(TotalCompleted) AS UnitsProduced, + SUM(TotalScrapped) AS UnitsScrapped, + AVG(ScrapRate) AS [Avg Scrap %], + SUM(ProductionValue) AS [Production Value] +FROM vw_ProductionCompletionSummary +GROUP BY Year, Month, ItemType +ORDER BY Year DESC, Month DESC, ItemType; +GO + +PRINT ''; +PRINT '18. Top 10 Most Used Raw Materials (by value)'; +PRINT '---------------------------------------------------------------------'; +SELECT TOP 10 + ItemName, + ItemCode, + SUM(TotalUsage) AS TotalUsageQty, + UnitCode, + SUM(TotalUsageValue) AS UsageValue, + AVG(AvgUnitCost) AS AvgCost, + COUNT(*) AS Periods +FROM vw_RawMaterialUsage +GROUP BY ItemName, ItemCode, UnitCode +ORDER BY SUM(TotalUsageValue) DESC; +GO + +PRINT ''; +PRINT '19. Purchase Order Aging Analysis'; +PRINT '---------------------------------------------------------------------'; +SELECT + DeliveryStatus AS Status, + COUNT(*) AS OrderCount, + SUM(TotalAmount) AS TotalValue, + AVG(DaysSinceOrder) AS AvgDaysOpen, + SUM(CASE WHEN Status = 'Received' THEN 0 ELSE TotalAmount END) AS OpenValue +FROM vw_PurchaseOrderStatus +GROUP BY DeliveryStatus +ORDER BY OpenValue DESC; +GO + +PRINT ''; +PRINT '20. ABC Inventory Classification (by value)'; +PRINT '---------------------------------------------------------------------'; +WITH InventoryTotals AS +( + SELECT + ItemCode, + ItemName, + ItemType, + QuantityOnHand, + InventoryValue, + SUM(InventoryValue) OVER () AS TotalInventoryValue + FROM vw_InventoryValuation +), +InventoryValueAnalysis AS +( + SELECT + ItemCode, + ItemName, + ItemType, + QuantityOnHand, + InventoryValue, + + InventoryValue * 100.0 + / NULLIF(TotalInventoryValue, 0) AS PercentOfTotal, + + SUM(InventoryValue) OVER + ( + ORDER BY InventoryValue DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) * 100.0 + / NULLIF(TotalInventoryValue, 0) AS CumulativePercent + FROM InventoryTotals +) +SELECT + ItemCode, + ItemName, + ItemType, + QuantityOnHand, + InventoryValue, + CAST(PercentOfTotal AS DECIMAL(5,2)) AS [% of Total], + CAST(CumulativePercent AS DECIMAL(5,2)) AS [Cumulative %], + CASE + WHEN CumulativePercent <= 80 THEN 'A' + WHEN CumulativePercent <= 95 THEN 'B' + ELSE 'C' + END AS ABCClass +FROM InventoryValueAnalysis +WHERE InventoryValue > 0 +ORDER BY InventoryValue DESC; +GO + +-- ============================================= +-- 21. WHAT-IF SCENARIOS +-- ============================================= + +PRINT ''; +PRINT '21. What-If: Material Requirements for New Sales Order'; +PRINT '---------------------------------------------------------------------'; +-- Example: What if we need to produce 10 Queen Luxury Futons? +DECLARE @ItemCode NVARCHAR(50) = 'FG-FUT-006'; +DECLARE @Quantity DECIMAL(18,2) = 10; + +WITH MaterialNeeds AS ( + SELECT + ComponentItemCode, + ComponentItemName, + ComponentType, + SUM(EffectiveQuantity * @Quantity) AS RequiredQty, + UnitCode, + MAX(StandardCost) AS UnitCost, + SUM(ExtendedCost * @Quantity) AS TotalCost + FROM vw_BOMExplosion + WHERE ParentItemCode = @ItemCode + GROUP BY ComponentItemCode, ComponentItemName, ComponentType, UnitCode +) +SELECT + mn.ComponentItemName AS Material, + mn.ComponentType AS Type, + mn.RequiredQty AS Required, + ISNULL(inv.QuantityAvailable, 0) AS Available, + mn.RequiredQty - ISNULL(inv.QuantityAvailable, 0) AS Shortage, + mn.UnitCode, + mn.UnitCost, + mn.TotalCost, + CASE + WHEN ISNULL(inv.QuantityAvailable, 0) >= mn.RequiredQty THEN 'OK' + WHEN ISNULL(inv.QuantityAvailable, 0) > 0 THEN 'Partial' + ELSE 'Out of Stock' + END AS Status +FROM MaterialNeeds mn +LEFT JOIN ( + SELECT i.ItemID, i.ItemCode, SUM(QuantityAvailable) AS QuantityAvailable + FROM Inventory inv + INNER JOIN Items i ON inv.ItemID = i.ItemID + GROUP BY i.ItemID, i.ItemCode +) inv ON mn.ComponentItemCode = inv.ItemCode +ORDER BY mn.ComponentType, mn.ComponentItemName; +GO + +-- ============================================= +-- 22. KEY PERFORMANCE INDICATORS (KPIs) +-- ============================================= + +PRINT ''; +PRINT '22. Manufacturing KPI Dashboard'; +PRINT '---------------------------------------------------------------------'; +SELECT + 'Total Inventory Value' AS KPI, + CAST(SUM(InventoryValue) AS DECIMAL(18,2)) AS Value, + NULL AS [Percent], + 'USD' AS Unit +FROM vw_InventoryValuation + +UNION ALL + +SELECT + 'Production Orders On Time', + COUNT(*), + CAST(COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM ProductionOrder WHERE Status = 'Completed'), 0) AS DECIMAL(5,2)), + '%' +FROM ProductionOrder +WHERE Status = 'Completed' AND ActualCompletionDate <= PlannedCompletionDate + +UNION ALL + +SELECT + 'Average Scrap Rate', + NULL, + CAST(AVG(ScrapRate) AS DECIMAL(5,2)), + '%' +FROM vw_ScrapWasteAnalysis + +UNION ALL + +SELECT + 'Supplier On-Time Delivery', + NULL, + CAST(AVG(OnTimeDeliveryRate) AS DECIMAL(5,2)), + '%' +FROM vw_SupplierPerformance + +UNION ALL + +SELECT + 'Customer Fulfillment Rate', + NULL, + CAST(AVG(FulfillmentRate) AS DECIMAL(5,2)), + '%' +FROM vw_CustomerFulfillmentRate + +UNION ALL + +SELECT + 'Items Below Reorder Point', + COUNT(*), + CAST(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM Items WHERE IsActive = 1) AS DECIMAL(5,2)), + '%' +FROM vw_ItemsBelowReorderPoint + +UNION ALL + +SELECT + 'Late Production Orders', + COUNT(*), + NULL, + 'orders' +FROM vw_LateProductionOrders + +UNION ALL + +SELECT + 'Open Sales Order Backlog Value', + SUM(BacklogValue), + NULL, + 'USD' +FROM vw_SalesOrderBacklog; +GO + +PRINT ''; +PRINT '============================================='; +PRINT 'Sample queries completed successfully!'; +PRINT 'Use these as templates for your own analysis.'; +PRINT '============================================='; +GO diff --git a/samples/databases/futon-manufacturing/05-sales-schema-enhancements.sql b/samples/databases/futon-manufacturing/05-sales-schema-enhancements.sql new file mode 100644 index 0000000000..38e677a66a --- /dev/null +++ b/samples/databases/futon-manufacturing/05-sales-schema-enhancements.sql @@ -0,0 +1,252 @@ +-- ============================================= +-- Sales Operations Schema Enhancements +-- Futon Manufacturing Database +-- ============================================= +-- Adds sales channel tracking, stores, and enhanced +-- sales operations capabilities +-- ============================================= + +USE FutonManufacturing; +GO + +-- ============================================= +-- Sales Channels and Stores +-- ============================================= + +CREATE TABLE SalesChannel ( + SalesChannelID INT IDENTITY(1,1) PRIMARY KEY, + ChannelCode NVARCHAR(20) NOT NULL UNIQUE, + ChannelName NVARCHAR(100) NOT NULL, + Description NVARCHAR(255), + IsActive BIT NOT NULL DEFAULT 1 +); + +INSERT INTO SalesChannel (ChannelCode, ChannelName, Description) VALUES +('RETAIL', 'Retail Store', 'Physical retail store sales'), +('ONLINE', 'Online/E-Commerce', 'Online website and marketplace sales'), +('WHOLESALE', 'Wholesale', 'Bulk sales to retailers and distributors'); + +CREATE TABLE Store ( + StoreID INT IDENTITY(1,1) PRIMARY KEY, + StoreCode NVARCHAR(20) NOT NULL UNIQUE, + StoreName NVARCHAR(100) NOT NULL, + SalesChannelID INT NOT NULL, + Manager NVARCHAR(100), + Phone NVARCHAR(20), + Email NVARCHAR(100), + Address NVARCHAR(255), + City NVARCHAR(100), + State NVARCHAR(50), + ZipCode NVARCHAR(20), + OpenDate DATE, + IsActive BIT NOT NULL DEFAULT 1, + CONSTRAINT FK_Store_SalesChannel FOREIGN KEY (SalesChannelID) REFERENCES SalesChannel(SalesChannelID) +); + +CREATE TABLE SalesTerritory ( + TerritoryID INT IDENTITY(1,1) PRIMARY KEY, + TerritoryCode NVARCHAR(20) NOT NULL UNIQUE, + TerritoryName NVARCHAR(100) NOT NULL, + Region NVARCHAR(50), + IsActive BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE SalesRep ( + SalesRepID INT IDENTITY(1,1) PRIMARY KEY, + EmployeeCode NVARCHAR(20) NOT NULL UNIQUE, + FirstName NVARCHAR(50) NOT NULL, + LastName NVARCHAR(50) NOT NULL, + Email NVARCHAR(100), + Phone NVARCHAR(20), + TerritoryID INT, + HireDate DATE, + IsActive BIT NOT NULL DEFAULT 1, + CONSTRAINT FK_SalesRep_Territory FOREIGN KEY (TerritoryID) REFERENCES SalesTerritory(TerritoryID) +); + +-- ============================================= +-- Enhance Existing Tables +-- ============================================= + +-- Add sales channel tracking to SalesOrder +ALTER TABLE SalesOrder ADD SalesChannelID INT NULL; +ALTER TABLE SalesOrder ADD StoreID INT NULL; +ALTER TABLE SalesOrder ADD SalesRepID INT NULL; +ALTER TABLE SalesOrder ADD DiscountAmount DECIMAL(18,2) DEFAULT 0; +GO + +ALTER TABLE SalesOrder ADD NetAmount AS (TotalAmount - DiscountAmount) PERSISTED; + +ALTER TABLE SalesOrder ADD CONSTRAINT FK_SalesOrder_SalesChannel + FOREIGN KEY (SalesChannelID) REFERENCES SalesChannel(SalesChannelID); +ALTER TABLE SalesOrder ADD CONSTRAINT FK_SalesOrder_Store + FOREIGN KEY (StoreID) REFERENCES Store(StoreID); +ALTER TABLE SalesOrder ADD CONSTRAINT FK_SalesOrder_SalesRep + FOREIGN KEY (SalesRepID) REFERENCES SalesRep(SalesRepID); + +-- Add discount tracking to SalesOrderDetail +ALTER TABLE SalesOrderDetail ADD DiscountPercent DECIMAL(5,2) DEFAULT 0; +GO + +ALTER TABLE SalesOrderDetail ADD DiscountAmount AS ((Quantity * UnitPrice) * DiscountPercent / 100) PERSISTED; +ALTER TABLE SalesOrderDetail ADD NetAmount AS ((Quantity * UnitPrice) - ((Quantity * UnitPrice) * DiscountPercent / 100)) PERSISTED; + +-- Add customer segmentation +ALTER TABLE Customer ADD CustomerType NVARCHAR(20) DEFAULT 'Retail'; -- Retail, Wholesale, Online +ALTER TABLE Customer ADD SalesRepID INT NULL; +ALTER TABLE Customer ADD TerritoryID INT NULL; +GO + +ALTER TABLE Customer ADD CONSTRAINT FK_Customer_SalesRep + FOREIGN KEY (SalesRepID) REFERENCES SalesRep(SalesRepID); +ALTER TABLE Customer ADD CONSTRAINT FK_Customer_Territory + FOREIGN KEY (TerritoryID) REFERENCES SalesTerritory(TerritoryID); + +-- ============================================= +-- Sales Returns and Exchanges +-- ============================================= + +CREATE TABLE ReturnReason ( + ReturnReasonID INT IDENTITY(1,1) PRIMARY KEY, + ReasonCode NVARCHAR(20) NOT NULL UNIQUE, + ReasonDescription NVARCHAR(255) NOT NULL, + IsActive BIT NOT NULL DEFAULT 1 +); + +INSERT INTO ReturnReason (ReasonCode, ReasonDescription) VALUES +('DEFECT', 'Product defect or quality issue'), +('DAMAGE', 'Damaged during shipping'), +('WRONG', 'Wrong item received'), +('NOFIT', 'Does not fit/wrong size'), +('EXPECT', 'Did not meet expectations'), +('CHANGE', 'Customer changed mind'), +('LATE', 'Delivery too late'), +('OTHER', 'Other reason'); + +CREATE TABLE SalesReturn ( + ReturnID INT IDENTITY(1,1) PRIMARY KEY, + ReturnNumber NVARCHAR(50) NOT NULL UNIQUE, + SalesOrderID INT NOT NULL, + CustomerID INT NOT NULL, + ReturnDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + ReturnReasonID INT NOT NULL, + Status NVARCHAR(20) NOT NULL DEFAULT 'Pending', -- Pending, Approved, Received, Refunded, Denied + RefundAmount DECIMAL(18,2) DEFAULT 0, + RestockingFee DECIMAL(18,2) DEFAULT 0, + Notes NVARCHAR(MAX), + ApprovedBy NVARCHAR(100), + ApprovedDate DATETIME2, + CreatedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_Return_SalesOrder FOREIGN KEY (SalesOrderID) REFERENCES SalesOrder(SalesOrderID), + CONSTRAINT FK_Return_Customer FOREIGN KEY (CustomerID) REFERENCES Customer(CustomerID), + CONSTRAINT FK_Return_Reason FOREIGN KEY (ReturnReasonID) REFERENCES ReturnReason(ReturnReasonID) +); + +CREATE TABLE SalesReturnDetail ( + ReturnDetailID INT IDENTITY(1,1) PRIMARY KEY, + ReturnID INT NOT NULL, + SODetailID INT NOT NULL, + ItemID INT NOT NULL, + QuantityReturned DECIMAL(18,2) NOT NULL, + UnitPrice DECIMAL(18,4) NOT NULL, + RefundAmount DECIMAL(18,2) NOT NULL, + Disposition NVARCHAR(50), -- Restock, Scrap, Repair, RMA + CONSTRAINT FK_ReturnDetail_Return FOREIGN KEY (ReturnID) REFERENCES SalesReturn(ReturnID), + CONSTRAINT FK_ReturnDetail_SODetail FOREIGN KEY (SODetailID) REFERENCES SalesOrderDetail(SODetailID), + CONSTRAINT FK_ReturnDetail_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Sales Quotations +-- ============================================= + +CREATE TABLE SalesQuote ( + QuoteID INT IDENTITY(1,1) PRIMARY KEY, + QuoteNumber NVARCHAR(50) NOT NULL UNIQUE, + CustomerID INT NOT NULL, + SalesChannelID INT, + SalesRepID INT, + QuoteDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + ExpirationDate DATE, + Status NVARCHAR(20) NOT NULL DEFAULT 'Draft', -- Draft, Sent, Accepted, Declined, Expired + Subtotal DECIMAL(18,2) DEFAULT 0, + DiscountAmount DECIMAL(18,2) DEFAULT 0, + TaxAmount DECIMAL(18,2) DEFAULT 0, + TotalAmount DECIMAL(18,2) DEFAULT 0, + ConvertedToOrderID INT NULL, + Notes NVARCHAR(MAX), + CreatedBy NVARCHAR(100), + CreatedDate DATETIME2 DEFAULT GETDATE(), + CONSTRAINT FK_Quote_Customer FOREIGN KEY (CustomerID) REFERENCES Customer(CustomerID), + CONSTRAINT FK_Quote_SalesChannel FOREIGN KEY (SalesChannelID) REFERENCES SalesChannel(SalesChannelID), + CONSTRAINT FK_Quote_SalesRep FOREIGN KEY (SalesRepID) REFERENCES SalesRep(SalesRepID), + CONSTRAINT FK_Quote_ConvertedOrder FOREIGN KEY (ConvertedToOrderID) REFERENCES SalesOrder(SalesOrderID) +); + +CREATE TABLE SalesQuoteDetail ( + QuoteDetailID INT IDENTITY(1,1) PRIMARY KEY, + QuoteID INT NOT NULL, + LineNumber INT NOT NULL, + ItemID INT NOT NULL, + Quantity DECIMAL(18,2) NOT NULL, + UnitPrice DECIMAL(18,4) NOT NULL, + DiscountPercent DECIMAL(5,2) DEFAULT 0, + LineTotal AS (Quantity * UnitPrice * (1 - DiscountPercent/100)) PERSISTED, + CONSTRAINT FK_QuoteDetail_Quote FOREIGN KEY (QuoteID) REFERENCES SalesQuote(QuoteID), + CONSTRAINT FK_QuoteDetail_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Promotions and Pricing +-- ============================================= + +CREATE TABLE Promotion ( + PromotionID INT IDENTITY(1,1) PRIMARY KEY, + PromotionCode NVARCHAR(50) NOT NULL UNIQUE, + PromotionName NVARCHAR(255) NOT NULL, + Description NVARCHAR(MAX), + DiscountPercent DECIMAL(5,2), + DiscountAmount DECIMAL(18,2), + StartDate DATE NOT NULL, + EndDate DATE NOT NULL, + IsActive BIT NOT NULL DEFAULT 1, + MinimumPurchase DECIMAL(18,2) DEFAULT 0, + ApplicableChannels NVARCHAR(255) -- CSV: RETAIL,ONLINE,WHOLESALE +); + +CREATE TABLE PriceList ( + PriceListID INT IDENTITY(1,1) PRIMARY KEY, + PriceListCode NVARCHAR(20) NOT NULL UNIQUE, + PriceListName NVARCHAR(100) NOT NULL, + SalesChannelID INT, + EffectiveDate DATE NOT NULL, + EndDate DATE, + IsActive BIT NOT NULL DEFAULT 1, + CONSTRAINT FK_PriceList_SalesChannel FOREIGN KEY (SalesChannelID) REFERENCES SalesChannel(SalesChannelID) +); + +CREATE TABLE PriceListDetail ( + PriceListDetailID INT IDENTITY(1,1) PRIMARY KEY, + PriceListID INT NOT NULL, + ItemID INT NOT NULL, + UnitPrice DECIMAL(18,4) NOT NULL, + MinimumQuantity DECIMAL(18,2) DEFAULT 1, + CONSTRAINT FK_PriceListDetail_PriceList FOREIGN KEY (PriceListID) REFERENCES PriceList(PriceListID), + CONSTRAINT FK_PriceListDetail_Item FOREIGN KEY (ItemID) REFERENCES Items(ItemID) +); + +-- ============================================= +-- Indexes for Performance +-- ============================================= + +CREATE NONCLUSTERED INDEX IX_SalesOrder_Channel ON SalesOrder(SalesChannelID, OrderDate); +CREATE NONCLUSTERED INDEX IX_SalesOrder_Store ON SalesOrder(StoreID, OrderDate); +CREATE NONCLUSTERED INDEX IX_SalesOrder_SalesRep ON SalesOrder(SalesRepID, OrderDate); +CREATE NONCLUSTERED INDEX IX_Customer_Type ON Customer(CustomerType); +CREATE NONCLUSTERED INDEX IX_SalesReturn_Date ON SalesReturn(ReturnDate); +CREATE NONCLUSTERED INDEX IX_SalesQuote_Status ON SalesQuote(Status, QuoteDate); + +GO + +PRINT 'Sales operations schema enhancements completed successfully!'; +GO diff --git a/samples/databases/futon-manufacturing/06-sales-sample-data.sql b/samples/databases/futon-manufacturing/06-sales-sample-data.sql new file mode 100644 index 0000000000..e17683ca36 --- /dev/null +++ b/samples/databases/futon-manufacturing/06-sales-sample-data.sql @@ -0,0 +1,366 @@ +-- ============================================= +-- Sales Operations Sample Data +-- Futon Manufacturing Database +-- ============================================= + +USE FutonManufacturing; +GO + +-- ============================================= +-- Sales Territories +-- ============================================= + +INSERT INTO SalesTerritory (TerritoryCode, TerritoryName, Region) VALUES +('NW-01', 'Pacific Northwest', 'West'), +('CA-01', 'Northern California', 'West'), +('CA-02', 'Southern California', 'West'), +('MW-01', 'Upper Midwest', 'Central'), +('SE-01', 'Southeast', 'East'), +('NE-01', 'Northeast', 'East'); + +-- ============================================= +-- Sales Representatives +-- ============================================= + +INSERT INTO SalesRep (EmployeeCode, FirstName, LastName, Email, Phone, TerritoryID, HireDate) VALUES +('SR-001', 'Michael', 'Johnson', 'mjohnson@futonmfg.com', '503-555-2001', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01'), '2020-03-15'), +('SR-002', 'Emily', 'Williams', 'ewilliams@futonmfg.com', '415-555-2002', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'CA-01'), '2019-06-20'), +('SR-003', 'David', 'Brown', 'dbrown@futonmfg.com', '310-555-2003', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'CA-02'), '2021-01-10'), +('SR-004', 'Sarah', 'Davis', 'sdavis@futonmfg.com', '312-555-2004', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'MW-01'), '2020-08-05'), +('SR-005', 'James', 'Miller', 'jmiller@futonmfg.com', '404-555-2005', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'SE-01'), '2018-11-12'), +('SR-006', 'Jessica', 'Wilson', 'jwilson@futonmfg.com', '617-555-2006', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NE-01'), '2019-04-18'), +('SR-007', 'Robert', 'Moore', 'rmoore@futonmfg.com', '206-555-2007', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01'), '2022-02-14'), +('SR-008', 'Amanda', 'Taylor', 'ataylor@futonmfg.com', '503-555-2008', (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01'), '2021-09-01'); + +-- ============================================= +-- Retail Stores +-- ============================================= + +DECLARE @RetailChannel INT = (SELECT SalesChannelID FROM SalesChannel WHERE ChannelCode = 'RETAIL'); +DECLARE @OnlineChannel INT = (SELECT SalesChannelID FROM SalesChannel WHERE ChannelCode = 'ONLINE'); +DECLARE @WholesaleChannel INT = (SELECT SalesChannelID FROM SalesChannel WHERE ChannelCode = 'WHOLESALE'); + +INSERT INTO Store (StoreCode, StoreName, SalesChannelID, Manager, Phone, Address, City, State, ZipCode, OpenDate) VALUES +-- Retail Stores +('STR-PDX-01', 'Portland Downtown Store', @RetailChannel, 'Lisa Anderson', '503-555-3001', '450 SW Broadway', 'Portland', 'OR', '97205', '2015-03-01'), +('STR-PDX-02', 'Portland East Side', @RetailChannel, 'Mark Thompson', '503-555-3002', '2200 E Burnside St', 'Portland', 'OR', '97214', '2018-06-15'), +('STR-SEA-01', 'Seattle Capitol Hill', @RetailChannel, 'Jennifer Lee', '206-555-3003', '1500 E Pine St', 'Seattle', 'WA', '98122', '2016-09-01'), +('STR-SF-01', 'San Francisco Store', @RetailChannel, 'Brian Chen', '415-555-3004', '850 Market St', 'San Francisco', 'CA', '94102', '2017-04-20'), +('STR-LA-01', 'Los Angeles Store', @RetailChannel, 'Maria Rodriguez', '310-555-3005', '1200 Wilshire Blvd', 'Los Angeles', 'CA', '90017', '2019-11-10'), +-- Online Channel (Virtual Store) +('ONLINE-01', 'E-Commerce Platform', @OnlineChannel, 'Thomas Wright', '503-555-4001', '1200 Industrial Parkway', 'Portland', 'OR', '97201', '2016-01-01'), +-- Wholesale (Virtual Store) +('WHSL-01', 'Wholesale Division', @WholesaleChannel, 'Patricia Martinez', '503-555-5001', '1200 Industrial Parkway', 'Portland', 'OR', '97201', '2015-01-01'); + +-- ============================================= +-- Update Existing Customers +-- ============================================= + +UPDATE Customer SET CustomerType = 'Retail', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-001'; + +UPDATE Customer SET CustomerType = 'Retail', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-002'; + +UPDATE Customer SET CustomerType = 'Retail', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-002'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'CA-01') +WHERE CustomerCode = 'CUST-003'; + +UPDATE Customer SET CustomerType = 'Wholesale', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-004'; + +UPDATE Customer SET CustomerType = 'Online', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-008'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-005'; + +UPDATE Customer SET CustomerType = 'Wholesale', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-006'; + +UPDATE Customer SET CustomerType = 'Retail', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-002'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'CA-01') +WHERE CustomerCode = 'CUST-007'; + +UPDATE Customer SET CustomerType = 'Wholesale', SalesRepID = (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + TerritoryID = (SELECT TerritoryID FROM SalesTerritory WHERE TerritoryCode = 'NW-01') +WHERE CustomerCode = 'CUST-008'; + +-- ============================================= +-- Sample Sales Orders with Channel Data +-- ============================================= + +-- Get IDs we'll need +DECLARE @MainWH INT = (SELECT WarehouseID FROM Warehouse WHERE WarehouseCode = 'WH-MAIN'); +DECLARE @WestWH INT = (SELECT WarehouseID FROM Warehouse WHERE WarehouseCode = 'WH-WEST'); +DECLARE @EastWH INT = (SELECT WarehouseID FROM Warehouse WHERE WarehouseCode = 'WH-EAST'); + +-- Sales Orders for October 2024 +INSERT INTO SalesOrder (OrderNumber, CustomerID, WarehouseID, SalesChannelID, StoreID, SalesRepID, OrderDate, RequestedDeliveryDate, ShipDate, Status, Subtotal, TaxAmount, ShippingAmount, DiscountAmount, TotalAmount, CreatedBy) VALUES +-- Retail Store Sales +('SO-2024-1001', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-001'), @MainWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-PDX-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-10-01', '2024-10-05', '2024-10-04', 'Delivered', 1199.97, 95.00, 50.00, 60.00, 1284.97, 'system'), + +('SO-2024-1002', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-003'), @WestWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-SF-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-002'), + '2024-10-02', '2024-10-08', '2024-10-07', 'Delivered', 1599.98, 128.00, 75.00, 0, 1802.98, 'system'), + +('SO-2024-1003', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-007'), @WestWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-LA-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-003'), + '2024-10-03', '2024-10-10', '2024-10-09', 'Delivered', 2399.97, 192.00, 100.00, 120.00, 2571.97, 'system'), + +-- Online Sales +('SO-2024-1004', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-005'), @MainWH, @OnlineChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'ONLINE-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-008'), + '2024-10-05', '2024-10-12', '2024-10-08', 'Delivered', 799.99, 64.00, 25.00, 40.00, 848.99, 'system'), + +('SO-2024-1005', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-005'), @MainWH, @OnlineChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'ONLINE-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-008'), + '2024-10-07', '2024-10-14', '2024-10-10', 'Delivered', 599.99, 48.00, 25.00, 0, 672.99, 'system'), + +-- Wholesale Orders +('SO-2024-1006', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-004'), @MainWH, @WholesaleChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'WHSL-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-10-08', '2024-10-20', '2024-10-18', 'Delivered', 9999.60, 0, 500.00, 999.96, 9499.64, 'system'), + +('SO-2024-1007', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-006'), @MainWH, @WholesaleChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'WHSL-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + '2024-10-10', '2024-10-25', '2024-10-22', 'Delivered', 7999.68, 0, 400.00, 800.00, 7599.68, 'system'), + +('SO-2024-1008', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-008'), @MainWH, @WholesaleChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'WHSL-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-10-12', '2024-10-30', '2024-10-28', 'Delivered', 5999.76, 0, 300.00, 600.00, 5699.76, 'system'), + +-- November Sales (Recent) +('SO-2024-1101', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-001'), @MainWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-PDX-02'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-11-01', '2024-11-08', '2024-11-05', 'Delivered', 899.99, 72.00, 50.00, 45.00, 976.99, 'system'), + +('SO-2024-1102', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-002'), @MainWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-SEA-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + '2024-11-02', '2024-11-10', NULL, 'Shipped', 1199.98, 96.00, 60.00, 0, 1355.98, 'system'), + +('SO-2024-1103', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-005'), @MainWH, @OnlineChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'ONLINE-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-008'), + '2024-11-03', '2024-11-12', NULL, 'InProduction', 1599.98, 128.00, 50.00, 80.00, 1697.98, 'system'), + +('SO-2024-1104', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-004'), @MainWH, @WholesaleChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'WHSL-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-11-04', '2024-11-20', NULL, 'Confirmed', 11999.40, 0, 600.00, 1200.00, 11399.40, 'system'), + +('SO-2024-1105', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-003'), @WestWH, @RetailChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'STR-SF-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-002'), + '2024-11-05', '2024-11-15', NULL, 'Confirmed', 2399.96, 192.00, 100.00, 120.00, 2571.96, 'system'), + +('SO-2024-1106', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-007'), @WestWH, @OnlineChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'ONLINE-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-008'), + '2024-11-06', '2024-11-16', NULL, 'Confirmed', 1799.97, 144.00, 50.00, 90.00, 1903.97, 'system'), + +('SO-2024-1107', (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-006'), @MainWH, @WholesaleChannel, + (SELECT StoreID FROM Store WHERE StoreCode = 'WHSL-01'), (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + '2024-11-07', '2024-11-25', NULL, 'Confirmed', 9599.52, 0, 500.00, 960.00, 9139.52, 'system'); + +-- ============================================= +-- Sales Order Details +-- ============================================= + +-- SO-2024-1001 (Retail - PDX Downtown) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1001'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 2, 399.99, 5.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1001'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), 1, 299.99, 0); + +-- SO-2024-1002 (Retail - SF) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1002'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), 1, 599.99, 0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1002'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), 1, 899.99, 0); + +-- SO-2024-1003 (Retail - LA) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1003'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), 2, 899.99, 5.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1003'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), 1, 799.99, 0); + +-- SO-2024-1004 (Online) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1004'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), 1, 799.99, 5.0); + +-- SO-2024-1005 (Online) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1005'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), 1, 599.99, 0); + +-- SO-2024-1006 (Wholesale - Large order) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1006'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), 12, 299.99, 10.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1006'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 15, 399.99, 10.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1006'), 3, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), 8, 499.99, 10.0); + +-- SO-2024-1007 (Wholesale) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1007'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 10, 399.99, 10.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1007'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), 10, 599.99, 10.0); + +-- SO-2024-1008 (Wholesale) +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1008'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), 20, 299.99, 10.0); + +-- November orders +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1101'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), 1, 899.99, 5.0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1102'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 3, 399.99, 0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1103'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), 2, 799.99, 5.0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1104'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 15, 399.99, 10.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1104'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), 10, 499.99, 10.0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1105'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), 3, 799.99, 5.0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1106'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), 3, 599.99, 5.0); + +INSERT INTO SalesOrderDetail (SalesOrderID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1107'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-001'), 16, 299.99, 10.0), +((SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1107'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 12, 399.99, 10.0); + +-- Update shipped quantities for delivered orders +UPDATE SalesOrderDetail SET QuantityShipped = Quantity +WHERE SalesOrderID IN ( + SELECT SalesOrderID FROM SalesOrder + WHERE Status IN ('Delivered', 'Shipped') +); + +-- ============================================= +-- Sample Sales Returns +-- ============================================= + +INSERT INTO SalesReturn (ReturnNumber, SalesOrderID, CustomerID, ReturnDate, ReturnReasonID, Status, RefundAmount, RestockingFee, ApprovedBy, ApprovedDate) VALUES +('RET-2024-001', + (SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1001'), + (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-001'), + '2024-10-10', + (SELECT ReturnReasonID FROM ReturnReason WHERE ReasonCode = 'CHANGE'), + 'Refunded', 399.99, 0, 'Manager1', '2024-10-10'), + +('RET-2024-002', + (SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1003'), + (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-007'), + '2024-10-15', + (SELECT ReturnReasonID FROM ReturnReason WHERE ReasonCode = 'DEFECT'), + 'Refunded', 899.99, 0, 'Manager2', '2024-10-15'); + +INSERT INTO SalesReturnDetail (ReturnID, SODetailID, ItemID, QuantityReturned, UnitPrice, RefundAmount, Disposition) VALUES +((SELECT ReturnID FROM SalesReturn WHERE ReturnNumber = 'RET-2024-001'), + (SELECT SODetailID FROM SalesOrderDetail WHERE SalesOrderID = (SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1001') AND LineNumber = 1), + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 1, 399.99, 399.99, 'Restock'), + +((SELECT ReturnID FROM SalesReturn WHERE ReturnNumber = 'RET-2024-002'), + (SELECT SODetailID FROM SalesOrderDetail WHERE SalesOrderID = (SELECT SalesOrderID FROM SalesOrder WHERE OrderNumber = 'SO-2024-1003') AND LineNumber = 1), + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-006'), 1, 899.99, 899.99, 'Scrap'); + +-- ============================================= +-- Sample Sales Quotes +-- ============================================= + +INSERT INTO SalesQuote (QuoteNumber, CustomerID, SalesChannelID, SalesRepID, QuoteDate, ExpirationDate, Status, Subtotal, DiscountAmount, TaxAmount, TotalAmount) VALUES +('QT-2024-001', + (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-004'), + @WholesaleChannel, + (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-001'), + '2024-11-05', '2024-12-05', 'Sent', 14999.25, 1500.00, 0, 13499.25), + +('QT-2024-002', + (SELECT CustomerID FROM Customer WHERE CustomerCode = 'CUST-006'), + @WholesaleChannel, + (SELECT SalesRepID FROM SalesRep WHERE EmployeeCode = 'SR-007'), + '2024-11-06', '2024-12-06', 'Sent', 11999.40, 1200.00, 0, 10799.40); + +INSERT INTO SalesQuoteDetail (QuoteID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT QuoteID FROM SalesQuote WHERE QuoteNumber = 'QT-2024-001'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-005'), 25, 499.99, 10.0), +((SELECT QuoteID FROM SalesQuote WHERE QuoteNumber = 'QT-2024-001'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-003'), 5, 599.99, 10.0); + +INSERT INTO SalesQuoteDetail (QuoteID, LineNumber, ItemID, Quantity, UnitPrice, DiscountPercent) VALUES +((SELECT QuoteID FROM SalesQuote WHERE QuoteNumber = 'QT-2024-002'), 1, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-002'), 20, 399.99, 10.0), +((SELECT QuoteID FROM SalesQuote WHERE QuoteNumber = 'QT-2024-002'), 2, + (SELECT ItemID FROM Items WHERE ItemCode = 'FG-FUT-004'), 10, 799.99, 10.0); + +-- ============================================= +-- Promotions +-- ============================================= + +INSERT INTO Promotion (PromotionCode, PromotionName, Description, DiscountPercent, StartDate, EndDate, IsActive, MinimumPurchase, ApplicableChannels) VALUES +('FALL2024', 'Fall Clearance Sale', 'Fall season clearance event', 15.0, '2024-09-15', '2024-11-30', 1, 500.00, 'RETAIL,ONLINE'), +('BULK10', 'Wholesale Bulk Discount', '10% off bulk wholesale orders', 10.0, '2024-01-01', '2024-12-31', 1, 5000.00, 'WHOLESALE'), +('BLACKFRI', 'Black Friday Special', 'Black Friday mega sale', 25.0, '2024-11-29', '2024-11-29', 1, 0, 'RETAIL,ONLINE'), +('HOLIDAY24', 'Holiday Season Sale', 'Holiday promotional pricing', 20.0, '2024-12-01', '2024-12-31', 1, 750.00, 'RETAIL,ONLINE'); + +-- ============================================= +-- Price Lists +-- ============================================= + +INSERT INTO PriceList (PriceListCode, PriceListName, SalesChannelID, EffectiveDate, IsActive) VALUES +('PL-RETAIL', 'Retail Price List', @RetailChannel, '2024-01-01', 1), +('PL-ONLINE', 'Online Price List', @OnlineChannel, '2024-01-01', 1), +('PL-WHOLESALE', 'Wholesale Price List', @WholesaleChannel, '2024-01-01', 1); + +-- Insert price list details for finished goods +INSERT INTO PriceListDetail (PriceListID, ItemID, UnitPrice, MinimumQuantity) +SELECT + (SELECT PriceListID FROM PriceList WHERE PriceListCode = 'PL-RETAIL'), + ItemID, + ListPrice, + 1 +FROM Items WHERE ItemTypeID = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'FG'); + +INSERT INTO PriceListDetail (PriceListID, ItemID, UnitPrice, MinimumQuantity) +SELECT + (SELECT PriceListID FROM PriceList WHERE PriceListCode = 'PL-ONLINE'), + ItemID, + ListPrice * 0.98, -- 2% discount for online + 1 +FROM Items WHERE ItemTypeID = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'FG'); + +INSERT INTO PriceListDetail (PriceListID, ItemID, UnitPrice, MinimumQuantity) +SELECT + (SELECT PriceListID FROM PriceList WHERE PriceListCode = 'PL-WHOLESALE'), + ItemID, + ListPrice * 0.85, -- 15% discount for wholesale base + 10 +FROM Items WHERE ItemTypeID = (SELECT ItemTypeID FROM ItemType WHERE TypeCode = 'FG'); + +GO + +PRINT 'Sales operations sample data inserted successfully!'; +GO diff --git a/samples/databases/futon-manufacturing/07-sales-reports.sql b/samples/databases/futon-manufacturing/07-sales-reports.sql new file mode 100644 index 0000000000..d075341575 --- /dev/null +++ b/samples/databases/futon-manufacturing/07-sales-reports.sql @@ -0,0 +1,908 @@ +-- ============================================= +-- Top 20 Sales Operations Reports +-- Futon Manufacturing Database +-- ============================================= + +USE FutonManufacturing; +GO + +-- ============================================= +-- REPORT 1: Sales Performance by Channel +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ByChannel AS +SELECT + sc.ChannelCode, + sc.ChannelName, + DATEPART(YEAR, so.OrderDate) AS Year, + DATEPART(MONTH, so.OrderDate) AS Month, + DATEPART(QUARTER, so.OrderDate) AS Quarter, + COUNT(DISTINCT so.SalesOrderID) AS OrderCount, + COUNT(DISTINCT so.CustomerID) AS UniqueCustomers, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS TotalDiscounts, + SUM(so.NetAmount) AS NetSales, + AVG(so.NetAmount) AS AvgOrderValue, + SUM(CASE WHEN so.Status = 'Delivered' THEN so.NetAmount ELSE 0 END) AS DeliveredSales, + SUM(CASE WHEN so.Status IN ('Confirmed', 'InProduction', 'Shipped') THEN so.NetAmount ELSE 0 END) AS PipelineSales, + CAST(AVG(so.DiscountAmount * 100.0 / NULLIF(so.Subtotal, 0)) AS DECIMAL(5,2)) AS AvgDiscountPercent +FROM SalesOrder so +INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +GROUP BY + sc.ChannelCode, + sc.ChannelName, + DATEPART(YEAR, so.OrderDate), + DATEPART(MONTH, so.OrderDate), + DATEPART(QUARTER, so.OrderDate); +GO + +-- ============================================= +-- REPORT 2: Store Performance Report +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_StorePerformance AS +SELECT + s.StoreCode, + s.StoreName, + s.City, + s.State, + sc.ChannelName, + s.Manager, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + COUNT(DISTINCT so.CustomerID) AS UniqueCustomers, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS TotalDiscounts, + SUM(so.NetAmount) AS NetSales, + AVG(so.NetAmount) AS AvgOrderValue, + SUM(CASE WHEN so.Status = 'Delivered' THEN 1 ELSE 0 END) AS CompletedOrders, + CAST(SUM(CASE WHEN so.Status = 'Delivered' THEN 1 ELSE 0 END) * 100.0 / + NULLIF(COUNT(so.SalesOrderID), 0) AS DECIMAL(5,2)) AS CompletionRate, + MIN(so.OrderDate) AS FirstSale, + MAX(so.OrderDate) AS LastSale, + DATEDIFF(DAY, MIN(so.OrderDate), MAX(so.OrderDate)) + 1 AS DaysActive, + SUM(so.NetAmount) / NULLIF(DATEDIFF(DAY, MIN(so.OrderDate), MAX(so.OrderDate)) + 1, 0) AS AvgDailySales +FROM Store s +LEFT JOIN SalesOrder so ON s.StoreID = so.StoreID +LEFT JOIN SalesChannel sc ON s.SalesChannelID = sc.SalesChannelID +GROUP BY + s.StoreCode, + s.StoreName, + s.City, + s.State, + sc.ChannelName, + s.Manager; +GO + +-- ============================================= +-- REPORT 3: Sales Representative Performance +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_RepPerformance AS +SELECT + sr.EmployeeCode, + sr.FirstName + ' ' + sr.LastName AS SalesRepName, + st.TerritoryName, + st.Region, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + COUNT(DISTINCT so.CustomerID) AS UniqueCustomers, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS TotalDiscounts, + SUM(so.NetAmount) AS NetSales, + AVG(so.NetAmount) AS AvgOrderValue, + SUM(CASE WHEN so.Status = 'Delivered' THEN so.NetAmount ELSE 0 END) AS DeliveredSales, + CAST(AVG(DATEDIFF(DAY, so.OrderDate, so.ShipDate)) AS DECIMAL(10,1)) AS AvgDaysToShip, + -- Quote performance + COUNT(DISTINCT sq.QuoteID) AS QuotesCreated, + SUM(CASE WHEN sq.Status = 'Accepted' THEN 1 ELSE 0 END) AS QuotesAccepted, + CAST(SUM(CASE WHEN sq.Status = 'Accepted' THEN 1 ELSE 0 END) * 100.0 / + NULLIF(COUNT(DISTINCT sq.QuoteID), 0) AS DECIMAL(5,2)) AS QuoteWinRate, + -- Rankings + RANK() OVER (ORDER BY SUM(so.NetAmount) DESC) AS SalesRank +FROM SalesRep sr +LEFT JOIN SalesTerritory st ON sr.TerritoryID = st.TerritoryID +LEFT JOIN SalesOrder so ON sr.SalesRepID = so.SalesRepID +LEFT JOIN SalesQuote sq ON sr.SalesRepID = sq.SalesRepID +WHERE sr.IsActive = 1 +GROUP BY + sr.EmployeeCode, + sr.FirstName, + sr.LastName, + st.TerritoryName, + st.Region; +GO + +-- ============================================= +-- REPORT 4: Customer Sales Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_CustomerAnalysis AS +WITH CustomerMetrics AS ( + SELECT + c.CustomerID, + c.CustomerCode, + c.CustomerName, + c.CustomerType, + c.City, + c.State, + sr.FirstName + ' ' + sr.LastName AS SalesRep, + st.TerritoryName, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + SUM(so.NetAmount) AS TotalSales, + AVG(so.NetAmount) AS AvgOrderValue, + MAX(so.OrderDate) AS LastOrderDate, + MIN(so.OrderDate) AS FirstOrderDate, + DATEDIFF(DAY, MIN(so.OrderDate), MAX(so.OrderDate)) AS CustomerLifespanDays, + SUM(CASE WHEN so.Status = 'Delivered' THEN so.NetAmount ELSE 0 END) AS DeliveredSales, + SUM(CASE WHEN so.Status IN ('Confirmed', 'InProduction') THEN so.NetAmount ELSE 0 END) AS PendingSales, + -- Returns + COUNT(DISTINCT sr2.ReturnID) AS TotalReturns, + ISNULL(SUM(sr2.RefundAmount), 0) AS TotalRefunds + FROM Customer c + LEFT JOIN SalesOrder so ON c.CustomerID = so.CustomerID + LEFT JOIN SalesRep sr ON c.SalesRepID = sr.SalesRepID + LEFT JOIN SalesTerritory st ON c.TerritoryID = st.TerritoryID + LEFT JOIN SalesReturn sr2 ON c.CustomerID = sr2.CustomerID + WHERE c.IsActive = 1 + GROUP BY + c.CustomerID, c.CustomerCode, c.CustomerName, c.CustomerType, + c.City, c.State, sr.FirstName, sr.LastName, st.TerritoryName +) +SELECT + *, + CASE + WHEN TotalOrders = 0 THEN 'No Orders' + WHEN TotalOrders = 1 THEN 'One-Time' + WHEN TotalOrders BETWEEN 2 AND 5 THEN 'Occasional' + WHEN TotalOrders BETWEEN 6 AND 10 THEN 'Regular' + ELSE 'VIP' + END AS CustomerSegment, + CAST(TotalRefunds * 100.0 / NULLIF(TotalSales, 0) AS DECIMAL(5,2)) AS ReturnRate, + DATEDIFF(DAY, LastOrderDate, GETDATE()) AS DaysSinceLastOrder, + CASE + WHEN DATEDIFF(DAY, LastOrderDate, GETDATE()) <= 30 THEN 'Active' + WHEN DATEDIFF(DAY, LastOrderDate, GETDATE()) <= 90 THEN 'Recent' + WHEN DATEDIFF(DAY, LastOrderDate, GETDATE()) <= 180 THEN 'Inactive' + ELSE 'Dormant' + END AS CustomerStatus, + TotalSales / NULLIF(CustomerLifespanDays / 30.0, 0) AS AvgMonthlyValue +FROM CustomerMetrics; +GO + +-- ============================================= +-- REPORT 5: Product Sales Performance +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ProductPerformance AS +SELECT + i.ItemCode, + i.ItemName, + COUNT(DISTINCT sod.SalesOrderID) AS OrderCount, + SUM(sod.Quantity) AS TotalUnitsSold, + SUM(sod.NetAmount) AS TotalRevenue, + AVG(sod.UnitPrice) AS AvgSellingPrice, + i.StandardCost AS UnitCost, + AVG(sod.UnitPrice) - i.StandardCost AS AvgGrossProfitPerUnit, + SUM(sod.NetAmount - (sod.Quantity * i.StandardCost)) AS TotalGrossProfit, + CAST((AVG(sod.UnitPrice) - i.StandardCost) * 100.0 / NULLIF(AVG(sod.UnitPrice), 0) + AS DECIMAL(5,2)) AS GrossMarginPercent, + AVG(sod.DiscountPercent) AS AvgDiscountPercent, + -- By Channel + SUM(CASE WHEN sc.ChannelCode = 'RETAIL' THEN sod.Quantity ELSE 0 END) AS RetailUnits, + SUM(CASE WHEN sc.ChannelCode = 'ONLINE' THEN sod.Quantity ELSE 0 END) AS OnlineUnits, + SUM(CASE WHEN sc.ChannelCode = 'WHOLESALE' THEN sod.Quantity ELSE 0 END) AS WholesaleUnits, + -- Rankings + RANK() OVER (ORDER BY SUM(sod.Quantity) DESC) AS UnitSalesRank, + RANK() OVER (ORDER BY SUM(sod.NetAmount) DESC) AS RevenueRank, + RANK() OVER (ORDER BY SUM(sod.NetAmount - (sod.Quantity * i.StandardCost)) DESC) AS ProfitRank +FROM Items i +INNER JOIN ItemType it ON i.ItemTypeID = it.ItemTypeID +LEFT JOIN SalesOrderDetail sod ON i.ItemID = sod.ItemID +LEFT JOIN SalesOrder so ON sod.SalesOrderID = so.SalesOrderID +LEFT JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +WHERE it.TypeCode = 'FG' AND i.IsActive = 1 +GROUP BY + i.ItemCode, + i.ItemName, + i.StandardCost; +GO + +-- ============================================= +-- REPORT 6: Sales Trend Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_TrendAnalysis AS +WITH MonthlySales AS ( + SELECT + DATEPART(YEAR, OrderDate) AS Year, + DATEPART(MONTH, OrderDate) AS Month, + DATEPART(QUARTER, OrderDate) AS Quarter, + DATEFROMPARTS(DATEPART(YEAR, OrderDate), DATEPART(MONTH, OrderDate), 1) AS MonthStart, + COUNT(DISTINCT SalesOrderID) AS OrderCount, + COUNT(DISTINCT CustomerID) AS UniqueCustomers, + SUM(Subtotal) AS GrossSales, + SUM(DiscountAmount) AS Discounts, + SUM(NetAmount) AS NetSales, + AVG(NetAmount) AS AvgOrderValue + FROM SalesOrder + GROUP BY + DATEPART(YEAR, OrderDate), + DATEPART(MONTH, OrderDate), + DATEPART(QUARTER, OrderDate), + DATEFROMPARTS(DATEPART(YEAR, OrderDate), DATEPART(MONTH, OrderDate), 1) +) +SELECT + Year, + Month, + Quarter, + MonthStart, + OrderCount, + UniqueCustomers, + GrossSales, + Discounts, + NetSales, + AvgOrderValue, + -- Prior month comparison + LAG(NetSales, 1) OVER (ORDER BY Year, Month) AS PriorMonthSales, + NetSales - LAG(NetSales, 1) OVER (ORDER BY Year, Month) AS MoMChange, + CAST((NetSales - LAG(NetSales, 1) OVER (ORDER BY Year, Month)) * 100.0 / + NULLIF(LAG(NetSales, 1) OVER (ORDER BY Year, Month), 0) AS DECIMAL(5,2)) AS MoMChangePercent, + -- Prior year comparison + LAG(NetSales, 12) OVER (ORDER BY Year, Month) AS PriorYearSales, + NetSales - LAG(NetSales, 12) OVER (ORDER BY Year, Month) AS YoYChange, + CAST((NetSales - LAG(NetSales, 12) OVER (ORDER BY Year, Month)) * 100.0 / + NULLIF(LAG(NetSales, 12) OVER (ORDER BY Year, Month), 0) AS DECIMAL(5,2)) AS YoYChangePercent, + -- Moving averages + AVG(NetSales) OVER (ORDER BY Year, Month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS ThreeMonthAvg, + AVG(NetSales) OVER (ORDER BY Year, Month ROWS BETWEEN 5 PRECEDING AND CURRENT ROW) AS SixMonthAvg +FROM MonthlySales; +GO + +-- ============================================= +-- REPORT 7: Average Order Value Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_OrderValueAnalysis AS +SELECT + sc.ChannelName, + c.CustomerType, + st.TerritoryName, + st.Region, + COUNT(DISTINCT so.SalesOrderID) AS OrderCount, + AVG(so.Subtotal) AS AvgSubtotal, + AVG(so.DiscountAmount) AS AvgDiscount, + AVG(so.NetAmount) AS AvgNetAmount, + AVG(so.TaxAmount) AS AvgTax, + AVG(so.ShippingAmount) AS AvgShipping, + MIN(so.NetAmount) AS MinOrderValue, + MAX(so.NetAmount) AS MaxOrderValue, + STDEV(so.NetAmount) AS StdDevOrderValue, + -- Order size buckets + SUM(CASE WHEN so.NetAmount < 500 THEN 1 ELSE 0 END) AS SmallOrders, + SUM(CASE WHEN so.NetAmount BETWEEN 500 AND 1999 THEN 1 ELSE 0 END) AS MediumOrders, + SUM(CASE WHEN so.NetAmount BETWEEN 2000 AND 4999 THEN 1 ELSE 0 END) AS LargeOrders, + SUM(CASE WHEN so.NetAmount >= 5000 THEN 1 ELSE 0 END) AS EnterpriseOrders +FROM SalesOrder so +INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +INNER JOIN Customer c ON so.CustomerID = c.CustomerID +LEFT JOIN SalesRep sr ON so.SalesRepID = sr.SalesRepID +LEFT JOIN SalesTerritory st ON sr.TerritoryID = st.TerritoryID +GROUP BY + sc.ChannelName, + c.CustomerType, + st.TerritoryName, + st.Region; +GO + +-- ============================================= +-- REPORT 8: Sales Returns Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ReturnsAnalysis AS +SELECT + sr.ReturnID, + DATEPART(YEAR, sr.ReturnDate) AS Year, + DATEPART(MONTH, sr.ReturnDate) AS Month, + rr.ReasonCode, + rr.ReasonDescription, + c.CustomerName, + c.CustomerType, + sc.ChannelName, + SUM(sr.RefundAmount) AS TotalRefunds, + AVG(sr.RefundAmount) AS AvgRefundAmount, + SUM(sr.RestockingFee) AS TotalRestockingFees, + -- Item details + i.ItemCode, + i.ItemName, + SUM(srd.QuantityReturned) AS UnitsReturned, + srd.Disposition, + -- Calculate return rate + COUNT(DISTINCT sr.ReturnID) * 100.0 / + NULLIF((SELECT COUNT(*) FROM SalesOrder WHERE Status = 'Delivered'), 0) AS ReturnRatePercent +FROM SalesReturn sr +INNER JOIN ReturnReason rr ON sr.ReturnReasonID = rr.ReturnReasonID +INNER JOIN Customer c ON sr.CustomerID = c.CustomerID +INNER JOIN SalesOrder so ON sr.SalesOrderID = so.SalesOrderID +INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +INNER JOIN SalesReturnDetail srd ON sr.ReturnID = srd.ReturnID +INNER JOIN Items i ON srd.ItemID = i.ItemID +GROUP BY + sr.ReturnID, + DATEPART(YEAR, sr.ReturnDate), + DATEPART(MONTH, sr.ReturnDate), + rr.ReasonCode, + rr.ReasonDescription, + c.CustomerName, + c.CustomerType, + sc.ChannelName, + i.ItemCode, + i.ItemName, + srd.Disposition; +GO + +-- ============================================= +-- REPORT 9: Sales Quote Conversion Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_QuoteConversion AS +SELECT + sq.QuoteNumber, + sq.Status AS QuoteStatus, + c.CustomerName, + c.CustomerType, + sc.ChannelName, + sr.FirstName + ' ' + sr.LastName AS SalesRep, + st.TerritoryName, + sq.QuoteDate, + sq.ExpirationDate, + DATEDIFF(DAY, sq.QuoteDate, sq.ExpirationDate) AS DaysToExpire, + sq.TotalAmount AS QuoteAmount, + CASE + WHEN sq.Status = 'Accepted' AND sq.ConvertedToOrderID IS NOT NULL THEN 'Converted' + WHEN sq.Status = 'Accepted' AND sq.ConvertedToOrderID IS NULL THEN 'Accepted - Pending' + WHEN sq.Status = 'Declined' THEN 'Lost' + WHEN sq.Status = 'Expired' THEN 'Expired' + ELSE 'Open' + END AS ConversionStatus, + so.OrderNumber AS ConvertedOrderNumber, + so.TotalAmount AS OrderAmount, + sq.TotalAmount - ISNULL(so.TotalAmount, 0) AS ValueVariance, + DATEDIFF(DAY, sq.QuoteDate, so.OrderDate) AS DaysToConvert +FROM SalesQuote sq +INNER JOIN Customer c ON sq.CustomerID = c.CustomerID +LEFT JOIN SalesChannel sc ON sq.SalesChannelID = sc.SalesChannelID +LEFT JOIN SalesRep sr ON sq.SalesRepID = sr.SalesRepID +LEFT JOIN SalesTerritory st ON sr.TerritoryID = st.TerritoryID +LEFT JOIN SalesOrder so ON sq.ConvertedToOrderID = so.SalesOrderID; +GO + +-- ============================================= +-- REPORT 10: Discount Analysis Report +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_DiscountAnalysis AS +SELECT + DATEPART(YEAR, so.OrderDate) AS Year, + DATEPART(MONTH, so.OrderDate) AS Month, + sc.ChannelName, + c.CustomerType, + COUNT(DISTINCT so.SalesOrderID) AS OrderCount, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS TotalDiscounts, + SUM(so.NetAmount) AS NetSales, + CAST(SUM(so.DiscountAmount) * 100.0 / NULLIF(SUM(so.Subtotal), 0) AS DECIMAL(5,2)) AS DiscountPercent, + AVG(so.DiscountAmount) AS AvgDiscountPerOrder, + -- By discount level + SUM(CASE WHEN so.DiscountAmount = 0 THEN 1 ELSE 0 END) AS NoDiscountOrders, + SUM(CASE WHEN so.DiscountAmount > 0 AND so.DiscountAmount <= 100 THEN 1 ELSE 0 END) AS LowDiscountOrders, + SUM(CASE WHEN so.DiscountAmount > 100 AND so.DiscountAmount <= 500 THEN 1 ELSE 0 END) AS MediumDiscountOrders, + SUM(CASE WHEN so.DiscountAmount > 500 THEN 1 ELSE 0 END) AS HighDiscountOrders, + -- Margin impact + SUM(so.NetAmount) - SUM(sod.Quantity * i.StandardCost) AS GrossProfit, + CAST((SUM(so.NetAmount) - SUM(sod.Quantity * i.StandardCost)) * 100.0 / + NULLIF(SUM(so.NetAmount), 0) AS DECIMAL(5,2)) AS GrossMarginPercent +FROM SalesOrder so +INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +INNER JOIN Customer c ON so.CustomerID = c.CustomerID +LEFT JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID +LEFT JOIN Items i ON sod.ItemID = i.ItemID +GROUP BY + DATEPART(YEAR, so.OrderDate), + DATEPART(MONTH, so.OrderDate), + sc.ChannelName, + c.CustomerType; +GO + +-- ============================================= +-- REPORT 11: Top Selling Products Report +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_TopProducts AS +WITH ProductSales AS ( + SELECT + i.ItemCode, + i.ItemName, + sc.ChannelName, + DATEPART(YEAR, so.OrderDate) AS Year, + DATEPART(MONTH, so.OrderDate) AS Month, + SUM(sod.Quantity) AS UnitsSold, + SUM(sod.NetAmount) AS Revenue, + SUM(sod.NetAmount - (sod.Quantity * i.StandardCost)) AS GrossProfit, + COUNT(DISTINCT so.CustomerID) AS UniqueCustomers + FROM Items i + INNER JOIN ItemType it ON i.ItemTypeID = it.ItemTypeID + INNER JOIN SalesOrderDetail sod ON i.ItemID = sod.ItemID + INNER JOIN SalesOrder so ON sod.SalesOrderID = so.SalesOrderID + INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID + WHERE it.TypeCode = 'FG' + GROUP BY + i.ItemCode, + i.ItemName, + sc.ChannelName, + DATEPART(YEAR, so.OrderDate), + DATEPART(MONTH, so.OrderDate) +) +SELECT + *, + RANK() OVER (PARTITION BY ChannelName, Year, Month ORDER BY UnitsSold DESC) AS UnitRank, + RANK() OVER (PARTITION BY ChannelName, Year, Month ORDER BY Revenue DESC) AS RevenueRank, + RANK() OVER (PARTITION BY ChannelName, Year, Month ORDER BY GrossProfit DESC) AS ProfitRank +FROM ProductSales; +GO + +-- ============================================= +-- REPORT 12: Sales by Territory Report +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ByTerritory AS +SELECT + st.TerritoryCode, + st.TerritoryName, + st.Region, + COUNT(DISTINCT sr.SalesRepID) AS SalesReps, + COUNT(DISTINCT c.CustomerID) AS TotalCustomers, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS Discounts, + SUM(so.NetAmount) AS NetSales, + AVG(so.NetAmount) AS AvgOrderValue, + SUM(so.NetAmount) / NULLIF(COUNT(DISTINCT sr.SalesRepID), 0) AS SalesPerRep, + SUM(so.NetAmount) / NULLIF(COUNT(DISTINCT c.CustomerID), 0) AS SalesPerCustomer, + -- Top products in territory + (SELECT TOP 1 i.ItemName + FROM SalesOrder so2 + INNER JOIN SalesOrderDetail sod ON so2.SalesOrderID = sod.SalesOrderID + INNER JOIN Items i ON sod.ItemID = i.ItemID + INNER JOIN Customer c2 ON so2.CustomerID = c2.CustomerID + WHERE c2.TerritoryID = st.TerritoryID + GROUP BY i.ItemName + ORDER BY SUM(sod.Quantity) DESC) AS TopProduct +FROM SalesTerritory st +LEFT JOIN SalesRep sr ON st.TerritoryID = sr.TerritoryID +LEFT JOIN Customer c ON st.TerritoryID = c.TerritoryID +LEFT JOIN SalesOrder so ON c.CustomerID = so.CustomerID +WHERE st.IsActive = 1 +GROUP BY + st.TerritoryCode, + st.TerritoryName, + st.Region, + st.TerritoryID; +GO + +-- ============================================= +-- REPORT 13: Channel Profitability Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ChannelProfitability AS +WITH ChannelMetrics AS ( + SELECT + sc.ChannelCode, + sc.ChannelName, + COUNT(DISTINCT so.SalesOrderID) AS OrderCount, + SUM(sod.Quantity) AS TotalUnits, + SUM(so.Subtotal) AS GrossSales, + SUM(so.DiscountAmount) AS Discounts, + SUM(so.NetAmount) AS NetSales, + SUM(so.ShippingAmount) AS ShippingRevenue, + SUM(sod.Quantity * i.StandardCost) AS COGS, + SUM(so.NetAmount - (sod.Quantity * i.StandardCost)) AS GrossProfit, + AVG(so.NetAmount) AS AvgOrderValue + FROM SalesChannel sc + LEFT JOIN SalesOrder so ON sc.SalesChannelID = so.SalesChannelID + LEFT JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID + LEFT JOIN Items i ON sod.ItemID = i.ItemID + GROUP BY sc.ChannelCode, sc.ChannelName +) +SELECT + ChannelCode, + ChannelName, + OrderCount, + TotalUnits, + GrossSales, + Discounts, + NetSales, + ShippingRevenue, + COGS, + GrossProfit, + AvgOrderValue, + CAST(GrossProfit * 100.0 / NULLIF(NetSales, 0) AS DECIMAL(5,2)) AS GrossMarginPercent, + CAST(Discounts * 100.0 / NULLIF(GrossSales, 0) AS DECIMAL(5,2)) AS DiscountPercent, + GrossProfit / NULLIF(OrderCount, 0) AS ProfitPerOrder, + GrossProfit / NULLIF(TotalUnits, 0) AS ProfitPerUnit, + RANK() OVER (ORDER BY GrossProfit DESC) AS ProfitabilityRank +FROM ChannelMetrics; +GO + +-- ============================================= +-- REPORT 14: Customer Lifetime Value +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_CustomerLifetimeValue AS +WITH CustomerValue AS ( + SELECT + c.CustomerID, + c.CustomerCode, + c.CustomerName, + c.CustomerType, + sr.FirstName + ' ' + sr.LastName AS SalesRep, + MIN(so.OrderDate) AS FirstPurchaseDate, + MAX(so.OrderDate) AS LastPurchaseDate, + DATEDIFF(MONTH, MIN(so.OrderDate), MAX(so.OrderDate)) + 1 AS CustomerLifetimeMonths, + COUNT(DISTINCT so.SalesOrderID) AS TotalOrders, + SUM(so.NetAmount) AS TotalRevenue, + SUM(so.NetAmount - ISNULL(sod.Quantity * i.StandardCost, 0)) AS TotalProfit, + AVG(so.NetAmount) AS AvgOrderValue, + SUM(so.NetAmount) / NULLIF(DATEDIFF(MONTH, MIN(so.OrderDate), MAX(so.OrderDate)) + 1, 0) AS AvgMonthlyRevenue + FROM Customer c + LEFT JOIN SalesOrder so ON c.CustomerID = so.CustomerID + LEFT JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID + LEFT JOIN Items i ON sod.ItemID = i.ItemID + LEFT JOIN SalesRep sr ON c.SalesRepID = sr.SalesRepID + GROUP BY + c.CustomerID, c.CustomerCode, c.CustomerName, c.CustomerType, + sr.FirstName, sr.LastName +) +SELECT + *, + CASE + WHEN TotalRevenue >= 20000 THEN 'Platinum' + WHEN TotalRevenue >= 10000 THEN 'Gold' + WHEN TotalRevenue >= 5000 THEN 'Silver' + WHEN TotalRevenue >= 1000 THEN 'Bronze' + ELSE 'Standard' + END AS CustomerTier, + DATEDIFF(DAY, LastPurchaseDate, GETDATE()) AS DaysSinceLastPurchase, + CASE + WHEN DATEDIFF(DAY, LastPurchaseDate, GETDATE()) <= 30 THEN 'Highly Active' + WHEN DATEDIFF(DAY, LastPurchaseDate, GETDATE()) <= 90 THEN 'Active' + WHEN DATEDIFF(DAY, LastPurchaseDate, GETDATE()) <= 180 THEN 'At Risk' + ELSE 'Churned' + END AS ActivityStatus, + -- Projected annual value + AvgMonthlyRevenue * 12 AS ProjectedAnnualRevenue, + RANK() OVER (ORDER BY TotalRevenue DESC) AS ValueRank +FROM CustomerValue +WHERE TotalOrders > 0; +GO + +-- ============================================= +-- REPORT 15: Sales Growth Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_GrowthAnalysis AS +WITH MonthlyGrowth AS ( + SELECT + DATEPART(YEAR, OrderDate) AS Year, + DATEPART(MONTH, OrderDate) AS Month, + sc.ChannelName, + COUNT(DISTINCT SalesOrderID) AS Orders, + COUNT(DISTINCT CustomerID) AS Customers, + SUM(NetAmount) AS Revenue + FROM SalesOrder so + INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID + GROUP BY + DATEPART(YEAR, OrderDate), + DATEPART(MONTH, OrderDate), + sc.ChannelName +) +SELECT + Year, + Month, + ChannelName, + Orders, + Customers, + Revenue, + -- Growth metrics + LAG(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month) AS PriorMonthRevenue, + Revenue - LAG(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month) AS RevenueGrowth, + CAST((Revenue - LAG(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month)) * 100.0 / + NULLIF(LAG(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month), 0) AS DECIMAL(5,2)) AS GrowthPercent, + LAG(Customers) OVER (PARTITION BY ChannelName ORDER BY Year, Month) AS PriorMonthCustomers, + Customers - LAG(Customers) OVER (PARTITION BY ChannelName ORDER BY Year, Month) AS CustomerGrowth, + -- Cumulative + SUM(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month) AS CumulativeRevenue, + -- Moving average + AVG(Revenue) OVER (PARTITION BY ChannelName ORDER BY Year, Month + ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS ThreeMonthAvg +FROM MonthlyGrowth; +GO + +-- ============================================= +-- REPORT 16: Order Size Distribution +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_OrderSizeDistribution AS +WITH OrderBuckets AS ( + SELECT + so.SalesOrderID, + sc.ChannelName, + c.CustomerType, + so.NetAmount, + COUNT(DISTINCT sod.SODetailID) AS LineItems, + SUM(sod.Quantity) AS TotalUnits, + CASE + WHEN so.NetAmount < 500 THEN 'Small (< $500)' + WHEN so.NetAmount < 1500 THEN 'Medium ($500-$1,499)' + WHEN so.NetAmount < 5000 THEN 'Large ($1,500-$4,999)' + ELSE 'Enterprise (>= $5,000)' + END AS OrderSizeBucket, + CASE + WHEN COUNT(DISTINCT sod.SODetailID) = 1 THEN 'Single Item' + WHEN COUNT(DISTINCT sod.SODetailID) BETWEEN 2 AND 3 THEN 'Small Basket' + WHEN COUNT(DISTINCT sod.SODetailID) BETWEEN 4 AND 6 THEN 'Medium Basket' + ELSE 'Large Basket' + END AS BasketSize + FROM SalesOrder so + INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID + INNER JOIN Customer c ON so.CustomerID = c.CustomerID + LEFT JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID + GROUP BY + so.SalesOrderID, + sc.ChannelName, + c.CustomerType, + so.NetAmount +) +SELECT + ChannelName, + CustomerType, + OrderSizeBucket, + BasketSize, + COUNT(*) AS OrderCount, + SUM(NetAmount) AS TotalRevenue, + AVG(NetAmount) AS AvgOrderValue, + AVG(TotalUnits) AS AvgUnitsPerOrder, + AVG(LineItems) AS AvgLineItems, + CAST(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY ChannelName) AS DECIMAL(5,2)) AS PercentOfChannelOrders +FROM OrderBuckets +GROUP BY + ChannelName, + CustomerType, + OrderSizeBucket, + BasketSize; +GO + +-- ============================================= +-- REPORT 17: Product Mix Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_ProductMixAnalysis AS +WITH ProductMix AS ( + SELECT + so.SalesOrderID, + so.OrderNumber, + sc.ChannelName, + c.CustomerType, + STRING_AGG(i.ItemCode, ', ') WITHIN GROUP (ORDER BY i.ItemCode) AS ProductMix, + COUNT(DISTINCT i.ItemID) AS UniqueProducts, + SUM(sod.Quantity) AS TotalUnits, + SUM(sod.NetAmount) AS OrderValue + FROM SalesOrder so + INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID + INNER JOIN Customer c ON so.CustomerID = c.CustomerID + INNER JOIN SalesOrderDetail sod ON so.SalesOrderID = sod.SalesOrderID + INNER JOIN Items i ON sod.ItemID = i.ItemID + GROUP BY + so.SalesOrderID, + so.OrderNumber, + sc.ChannelName, + c.CustomerType +) +SELECT + ChannelName, + CustomerType, + ProductMix, + UniqueProducts, + COUNT(*) AS OrderCount, + SUM(OrderValue) AS TotalRevenue, + AVG(OrderValue) AS AvgOrderValue, + AVG(TotalUnits) AS AvgUnits, + RANK() OVER (PARTITION BY ChannelName ORDER BY COUNT(*) DESC) AS PopularityRank +FROM ProductMix +GROUP BY + ChannelName, + CustomerType, + ProductMix, + UniqueProducts; +GO + +-- ============================================= +-- REPORT 18: Day of Week / Time-Based Analysis +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_TimeBasedAnalysis AS +SELECT + DATENAME(WEEKDAY, so.OrderDate) AS DayOfWeek, + DATEPART(WEEKDAY, so.OrderDate) AS DayNumber, + DATEPART(HOUR, so.CreatedDate) AS HourOfDay, + CASE + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 6 AND 11 THEN 'Morning (6-11)' + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 12 AND 17 THEN 'Afternoon (12-17)' + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 18 AND 21 THEN 'Evening (18-21)' + ELSE 'Night (22-5)' + END AS TimeOfDay, + sc.ChannelName, + COUNT(DISTINCT so.SalesOrderID) AS OrderCount, + SUM(so.NetAmount) AS TotalRevenue, + AVG(so.NetAmount) AS AvgOrderValue, + COUNT(DISTINCT so.CustomerID) AS UniqueCustomers +FROM SalesOrder so +INNER JOIN SalesChannel sc ON so.SalesChannelID = sc.SalesChannelID +GROUP BY + DATENAME(WEEKDAY, so.OrderDate), + DATEPART(WEEKDAY, so.OrderDate), + DATEPART(HOUR, so.CreatedDate), + CASE + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 6 AND 11 THEN 'Morning (6-11)' + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 12 AND 17 THEN 'Afternoon (12-17)' + WHEN DATEPART(HOUR, so.CreatedDate) BETWEEN 18 AND 21 THEN 'Evening (18-21)' + ELSE 'Night (22-5)' + END, + sc.ChannelName; +GO + +-- ============================================= +-- REPORT 19: Sales Pipeline (Quotes to Orders) +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_Pipeline AS +WITH PipelineMetrics AS ( + SELECT + 'Quotes' AS Stage, + 1 AS StageOrder, + COUNT(*) AS Count, + SUM(TotalAmount) AS Value + FROM SalesQuote + WHERE Status NOT IN ('Expired', 'Declined') + + UNION ALL + + SELECT + 'Quotes - Accepted' AS Stage, + 2 AS StageOrder, + COUNT(*) AS Count, + SUM(TotalAmount) AS Value + FROM SalesQuote + WHERE Status = 'Accepted' + + UNION ALL + + SELECT + 'Orders - Confirmed' AS Stage, + 3 AS StageOrder, + COUNT(*) AS Count, + SUM(NetAmount) AS Value + FROM SalesOrder + WHERE Status = 'Confirmed' + + UNION ALL + + SELECT + 'Orders - In Production' AS Stage, + 4 AS StageOrder, + COUNT(*) AS Count, + SUM(NetAmount) AS Value + FROM SalesOrder + WHERE Status = 'InProduction' + + UNION ALL + + SELECT + 'Orders - Shipped' AS Stage, + 5 AS StageOrder, + COUNT(*) AS Count, + SUM(NetAmount) AS Value + FROM SalesOrder + WHERE Status = 'Shipped' + + UNION ALL + + SELECT + 'Orders - Delivered' AS Stage, + 6 AS StageOrder, + COUNT(*) AS Count, + SUM(NetAmount) AS Value + FROM SalesOrder + WHERE Status = 'Delivered' +) +SELECT + Stage, + StageOrder, + Count, + Value, + LAG(Count) OVER (ORDER BY StageOrder) AS PriorStageCount, + LAG(Value) OVER (ORDER BY StageOrder) AS PriorStageValue, + CAST(Count * 100.0 / NULLIF(LAG(Count) OVER (ORDER BY StageOrder), 0) AS DECIMAL(10,2)) AS ConversionRate, + CAST(Value * 100.0 / NULLIF(LAG(Value) OVER (ORDER BY StageOrder), 0) AS DECIMAL(10,2)) AS ValueRetentionRate +FROM PipelineMetrics; +GO + +-- ============================================= +-- REPORT 20: Customer Segmentation RFM Analysis +-- (Recency, Frequency, Monetary) +-- ============================================= + +CREATE OR ALTER VIEW vw_Sales_CustomerSegmentation AS +WITH RFMScores AS ( + SELECT + c.CustomerID, + c.CustomerCode, + c.CustomerName, + c.CustomerType, + DATEDIFF(DAY, MAX(so.OrderDate), GETDATE()) AS Recency, + COUNT(DISTINCT so.SalesOrderID) AS Frequency, + SUM(so.NetAmount) AS Monetary, + NTILE(5) OVER (ORDER BY DATEDIFF(DAY, MAX(so.OrderDate), GETDATE())) AS R_Score, + NTILE(5) OVER (ORDER BY COUNT(DISTINCT so.SalesOrderID) DESC) AS F_Score, + NTILE(5) OVER (ORDER BY SUM(so.NetAmount) DESC) AS M_Score + FROM Customer c + LEFT JOIN SalesOrder so ON c.CustomerID = so.CustomerID + WHERE so.Status = 'Delivered' + GROUP BY c.CustomerID, c.CustomerCode, c.CustomerName, c.CustomerType +) +SELECT + *, + (R_Score + F_Score + M_Score) / 3.0 AS RFM_Score, + CASE + WHEN R_Score >= 4 AND F_Score >= 4 AND M_Score >= 4 THEN 'Champions' + WHEN R_Score >= 3 AND F_Score >= 3 AND M_Score >= 3 THEN 'Loyal Customers' + WHEN R_Score >= 4 AND F_Score <= 2 THEN 'New Customers' + WHEN R_Score <= 2 AND F_Score >= 3 THEN 'At Risk' + WHEN R_Score <= 2 AND F_Score <= 2 THEN 'Lost' + WHEN M_Score >= 4 THEN 'Big Spenders' + ELSE 'Others' + END AS CustomerSegment, + CASE + WHEN R_Score >= 4 AND F_Score >= 4 AND M_Score >= 4 THEN 'Maintain relationship, offer loyalty rewards' + WHEN R_Score >= 3 AND F_Score >= 3 AND M_Score >= 3 THEN 'Upsell higher value products' + WHEN R_Score >= 4 AND F_Score <= 2 THEN 'Build relationship, increase frequency' + WHEN R_Score <= 2 AND F_Score >= 3 THEN 'Win back campaign, special offers' + WHEN R_Score <= 2 AND F_Score <= 2 THEN 'Reactivation campaign' + WHEN M_Score >= 4 THEN 'Focus on satisfaction and retention' + ELSE 'Increase engagement' + END AS RecommendedAction +FROM RFMScores; +GO + +PRINT 'All 20 sales operations reports created successfully!'; +PRINT ''; +PRINT 'Available Sales Reports:'; +PRINT '1. vw_Sales_ByChannel - Sales Performance by Channel'; +PRINT '2. vw_Sales_StorePerformance - Store Performance Report'; +PRINT '3. vw_Sales_RepPerformance - Sales Representative Performance'; +PRINT '4. vw_Sales_CustomerAnalysis - Customer Sales Analysis'; +PRINT '5. vw_Sales_ProductPerformance - Product Sales Performance'; +PRINT '6. vw_Sales_TrendAnalysis - Sales Trend Analysis'; +PRINT '7. vw_Sales_OrderValueAnalysis - Average Order Value Analysis'; +PRINT '8. vw_Sales_ReturnsAnalysis - Sales Returns Analysis'; +PRINT '9. vw_Sales_QuoteConversion - Sales Quote Conversion Analysis'; +PRINT '10. vw_Sales_DiscountAnalysis - Discount Analysis Report'; +PRINT '11. vw_Sales_TopProducts - Top Selling Products Report'; +PRINT '12. vw_Sales_ByTerritory - Sales by Territory Report'; +PRINT '13. vw_Sales_ChannelProfitability - Channel Profitability Analysis'; +PRINT '14. vw_Sales_CustomerLifetimeValue - Customer Lifetime Value'; +PRINT '15. vw_Sales_GrowthAnalysis - Sales Growth Analysis'; +PRINT '16. vw_Sales_OrderSizeDistribution - Order Size Distribution'; +PRINT '17. vw_Sales_ProductMixAnalysis - Product Mix Analysis'; +PRINT '18. vw_Sales_TimeBasedAnalysis - Day of Week / Time-Based Analysis'; +PRINT '19. vw_Sales_Pipeline - Sales Pipeline (Quotes to Orders)'; +PRINT '20. vw_Sales_CustomerSegmentation - Customer Segmentation RFM Analysis'; +GO diff --git a/samples/databases/futon-manufacturing/08-sales-sample-queries.sql b/samples/databases/futon-manufacturing/08-sales-sample-queries.sql new file mode 100644 index 0000000000..1832393623 --- /dev/null +++ b/samples/databases/futon-manufacturing/08-sales-sample-queries.sql @@ -0,0 +1,593 @@ +-- ============================================= +-- Sample Sales Operations Queries +-- Futon Manufacturing Database +-- ============================================= + +USE FutonManufacturing; +GO + +PRINT '============================================='; +PRINT 'SAMPLE SALES OPERATIONS QUERIES'; +PRINT '============================================='; +PRINT ''; + +-- ============================================= +-- 1. SALES BY CHANNEL +-- ============================================= + +PRINT '1. Sales Performance by Channel - Current Month'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + Year, + Month, + OrderCount, + UniqueCustomers, + CAST(GrossSales AS DECIMAL(18,2)) AS GrossSales, + CAST(TotalDiscounts AS DECIMAL(18,2)) AS Discounts, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + AvgDiscountPercent AS [Discount %] +FROM vw_Sales_ByChannel +WHERE Year = YEAR(GETDATE()) AND Month = MONTH(GETDATE()) +ORDER BY NetSales DESC; +GO + +PRINT ''; +PRINT '2. Channel Performance Comparison - Last 3 Months'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + SUM(OrderCount) AS TotalOrders, + SUM(UniqueCustomers) AS TotalCustomers, + CAST(SUM(NetSales) AS DECIMAL(18,2)) AS TotalSales, + CAST(AVG(AvgOrderValue) AS DECIMAL(18,2)) AS AvgOrderValue +FROM vw_Sales_ByChannel +WHERE DATEFROMPARTS(Year, Month, 1) >= DATEADD(MONTH, -3, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)) +GROUP BY ChannelName +ORDER BY TotalSales DESC; +GO + +-- ============================================= +-- 2. STORE PERFORMANCE +-- ============================================= + +PRINT ''; +PRINT '3. Top Performing Stores by Sales'; +PRINT '---------------------------------------------------------------------'; +SELECT + StoreCode, + StoreName, + City, + State, + Manager, + TotalOrders, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + CAST(AvgDailySales AS DECIMAL(18,2)) AS AvgDailySales, + CompletionRate AS [Completion %] +FROM vw_Sales_StorePerformance +WHERE TotalOrders > 0 +ORDER BY NetSales DESC; +GO + +-- ============================================= +-- 3. SALES REP PERFORMANCE +-- ============================================= + +PRINT ''; +PRINT '4. Sales Rep Leaderboard'; +PRINT '---------------------------------------------------------------------'; +SELECT + SalesRepName, + TerritoryName, + Region, + TotalOrders, + UniqueCustomers, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + QuotesCreated, + QuotesAccepted, + QuoteWinRate AS [Win Rate %], + SalesRank +FROM vw_Sales_RepPerformance +ORDER BY SalesRank; +GO + +-- ============================================= +-- 4. CUSTOMER ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '5. Top 10 Customers by Total Sales'; +PRINT '---------------------------------------------------------------------'; +SELECT TOP 10 + CustomerCode, + CustomerName, + CustomerType, + City, + State, + TotalOrders, + CAST(TotalSales AS DECIMAL(18,2)) AS TotalSales, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + CustomerSegment, + CustomerStatus, + DaysSinceLastOrder +FROM vw_Sales_CustomerAnalysis +ORDER BY TotalSales DESC; +GO + +PRINT ''; +PRINT '6. At-Risk Customers (Inactive or Dormant)'; +PRINT '---------------------------------------------------------------------'; +SELECT + CustomerName, + CustomerType, + TotalOrders, + CAST(TotalSales AS DECIMAL(18,2)) AS TotalSales, + LastOrderDate, + DaysSinceLastOrder, + CustomerStatus, + SalesRep +FROM vw_Sales_CustomerAnalysis +WHERE CustomerStatus IN ('Inactive', 'Dormant') +ORDER BY TotalSales DESC; +GO + +-- ============================================= +-- 5. PRODUCT PERFORMANCE +-- ============================================= + +PRINT ''; +PRINT '7. Top Selling Products - All Time'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemCode, + ItemName, + OrderCount, + TotalUnitsSold, + CAST(TotalRevenue AS DECIMAL(18,2)) AS Revenue, + CAST(AvgSellingPrice AS DECIMAL(18,2)) AS AvgPrice, + CAST(TotalGrossProfit AS DECIMAL(18,2)) AS GrossProfit, + GrossMarginPercent AS [Margin %], + UnitSalesRank, + RevenueRank +FROM vw_Sales_ProductPerformance +ORDER BY TotalUnitsSold DESC; +GO + +PRINT ''; +PRINT '8. Product Performance by Channel'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemName, + RetailUnits, + OnlineUnits, + WholesaleUnits, + TotalUnitsSold, + CAST(TotalRevenue AS DECIMAL(18,2)) AS Revenue +FROM vw_Sales_ProductPerformance +ORDER BY TotalUnitsSold DESC; +GO + +-- ============================================= +-- 6. SALES TRENDS +-- ============================================= + +PRINT ''; +PRINT '9. Monthly Sales Trend'; +PRINT '---------------------------------------------------------------------'; +SELECT + Year, + Month, + OrderCount, + UniqueCustomers, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + CAST(PriorMonthSales AS DECIMAL(18,2)) AS PriorMonthSales, + CAST(MoMChange AS DECIMAL(18,2)) AS MoMChange, + MoMChangePercent AS [MoM %], + CAST(ThreeMonthAvg AS DECIMAL(18,2)) AS [3-Month Avg] +FROM vw_Sales_TrendAnalysis +ORDER BY Year DESC, Month DESC; +GO + +-- ============================================= +-- 7. RETURNS ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '10. Sales Returns Summary by Reason'; +PRINT '---------------------------------------------------------------------'; +SELECT + ReasonDescription, + COUNT(DISTINCT ReturnID) AS ReturnCount, + CAST(SUM(TotalRefunds) AS DECIMAL(18,2)) AS TotalRefunds, + CAST(AVG(AvgRefundAmount) AS DECIMAL(18,2)) AS AvgRefundAmount, + STRING_AGG(ItemName, ', ') AS ProductsReturned +FROM vw_Sales_ReturnsAnalysis +GROUP BY ReasonDescription +ORDER BY SUM(TotalRefunds) DESC; +GO + +PRINT ''; +PRINT '11. Returns by Product'; +PRINT '---------------------------------------------------------------------'; +SELECT + ItemName, + SUM(UnitsReturned) AS TotalReturned, + CAST(SUM(TotalRefunds) AS DECIMAL(18,2)) AS RefundAmount, + STRING_AGG(ReasonDescription, ', ') AS ReturnReasons +FROM vw_Sales_ReturnsAnalysis +GROUP BY ItemName +ORDER BY SUM(UnitsReturned) DESC; +GO + +-- ============================================= +-- 8. QUOTE CONVERSION +-- ============================================= + +PRINT ''; +PRINT '12. Sales Quote Conversion Rates'; +PRINT '---------------------------------------------------------------------'; +SELECT + QuoteStatus, + ConversionStatus, + COUNT(*) AS QuoteCount, + CAST(AVG(QuoteAmount) AS DECIMAL(18,2)) AS AvgQuoteAmount, + CAST(AVG(DaysToConvert) AS DECIMAL(10,1)) AS AvgDaysToConvert +FROM vw_Sales_QuoteConversion +GROUP BY QuoteStatus, ConversionStatus +ORDER BY QuoteCount DESC; +GO + +PRINT ''; +PRINT '13. Open Quotes Requiring Follow-Up'; +PRINT '---------------------------------------------------------------------'; +SELECT + QuoteNumber, + CustomerName, + SalesRep, + QuoteDate, + ExpirationDate, + DATEDIFF(DAY, GETDATE(), ExpirationDate) AS DaysUntilExpiration, + CAST(QuoteAmount AS DECIMAL(18,2)) AS QuoteAmount, + QuoteStatus +FROM vw_Sales_QuoteConversion +WHERE QuoteStatus IN ('Draft', 'Sent') + AND ExpirationDate >= GETDATE() +ORDER BY ExpirationDate; +GO + +-- ============================================= +-- 9. DISCOUNT ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '14. Discount Impact by Channel'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + OrderCount, + CAST(GrossSales AS DECIMAL(18,2)) AS GrossSales, + CAST(TotalDiscounts AS DECIMAL(18,2)) AS TotalDiscounts, + DiscountPercent AS [Discount %], + CAST(GrossProfit AS DECIMAL(18,2)) AS GrossProfit, + GrossMarginPercent AS [Margin %], + NoDiscountOrders, + LowDiscountOrders, + MediumDiscountOrders, + HighDiscountOrders +FROM vw_Sales_DiscountAnalysis +WHERE Year = YEAR(GETDATE()) +GROUP BY ChannelName, OrderCount, GrossSales, TotalDiscounts, DiscountPercent, + GrossProfit, GrossMarginPercent, NoDiscountOrders, LowDiscountOrders, + MediumDiscountOrders, HighDiscountOrders +ORDER BY DiscountPercent DESC; +GO + +-- ============================================= +-- 10. TOP PRODUCTS +-- ============================================= + +PRINT ''; +PRINT '15. Top 5 Products per Channel - Current Month'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + ItemName, + UnitsSold, + CAST(Revenue AS DECIMAL(18,2)) AS Revenue, + CAST(GrossProfit AS DECIMAL(18,2)) AS GrossProfit, + UnitRank +FROM vw_Sales_TopProducts +WHERE Year = YEAR(GETDATE()) + AND Month = MONTH(GETDATE()) + AND UnitRank <= 5 +ORDER BY ChannelName, UnitRank; +GO + +-- ============================================= +-- 11. TERRITORY ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '16. Territory Performance Summary'; +PRINT '---------------------------------------------------------------------'; +SELECT + TerritoryName, + Region, + SalesReps, + TotalCustomers, + TotalOrders, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(SalesPerRep AS DECIMAL(18,2)) AS SalesPerRep, + CAST(SalesPerCustomer AS DECIMAL(18,2)) AS SalesPerCustomer, + TopProduct +FROM vw_Sales_ByTerritory +ORDER BY NetSales DESC; +GO + +-- ============================================= +-- 12. CHANNEL PROFITABILITY +-- ============================================= + +PRINT ''; +PRINT '17. Channel Profitability Comparison'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + OrderCount, + TotalUnits, + CAST(GrossSales AS DECIMAL(18,2)) AS GrossSales, + CAST(Discounts AS DECIMAL(18,2)) AS Discounts, + CAST(NetSales AS DECIMAL(18,2)) AS NetSales, + CAST(COGS AS DECIMAL(18,2)) AS COGS, + CAST(GrossProfit AS DECIMAL(18,2)) AS GrossProfit, + GrossMarginPercent AS [Margin %], + CAST(ProfitPerOrder AS DECIMAL(18,2)) AS ProfitPerOrder, + ProfitabilityRank +FROM vw_Sales_ChannelProfitability +ORDER BY ProfitabilityRank; +GO + +-- ============================================= +-- 13. CUSTOMER LIFETIME VALUE +-- ============================================= + +PRINT ''; +PRINT '18. Top 10 Customers by Lifetime Value'; +PRINT '---------------------------------------------------------------------'; +SELECT TOP 10 + CustomerCode, + CustomerName, + CustomerType, + TotalOrders, + CAST(TotalRevenue AS DECIMAL(18,2)) AS TotalRevenue, + CAST(TotalProfit AS DECIMAL(18,2)) AS TotalProfit, + CustomerLifetimeMonths, + CAST(AvgMonthlyRevenue AS DECIMAL(18,2)) AS AvgMonthlyRevenue, + CustomerTier, + ActivityStatus, + ValueRank +FROM vw_Sales_CustomerLifetimeValue +ORDER BY ValueRank; +GO + +-- ============================================= +-- 14. GROWTH ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '19. Sales Growth by Channel - Last 6 Months'; +PRINT '---------------------------------------------------------------------'; +SELECT + Year, + Month, + ChannelName, + Orders, + Customers, + CAST(Revenue AS DECIMAL(18,2)) AS Revenue, + CAST(PriorMonthRevenue AS DECIMAL(18,2)) AS PriorMonthRevenue, + CAST(RevenueGrowth AS DECIMAL(18,2)) AS Growth, + GrowthPercent AS [Growth %], + CAST(ThreeMonthAvg AS DECIMAL(18,2)) AS [3-Mo Avg] +FROM vw_Sales_GrowthAnalysis +WHERE DATEFROMPARTS(Year, Month, 1) >= DATEADD(MONTH, -6, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)) +ORDER BY Year DESC, Month DESC, ChannelName; +GO + +-- ============================================= +-- 15. ORDER SIZE DISTRIBUTION +-- ============================================= + +PRINT ''; +PRINT '20. Order Size Distribution by Channel'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + OrderSizeBucket, + OrderCount, + CAST(TotalRevenue AS DECIMAL(18,2)) AS Revenue, + CAST(AvgOrderValue AS DECIMAL(18,2)) AS AvgOrderValue, + CAST(AvgUnitsPerOrder AS DECIMAL(10,1)) AS AvgUnits, + PercentOfChannelOrders AS [% of Orders] +FROM vw_Sales_OrderSizeDistribution +GROUP BY ChannelName, OrderSizeBucket, OrderCount, TotalRevenue, + AvgOrderValue, AvgUnitsPerOrder, PercentOfChannelOrders +ORDER BY ChannelName, OrderSizeBucket; +GO + +-- ============================================= +-- 16. TIME-BASED ANALYSIS +-- ============================================= + +PRINT ''; +PRINT '21. Sales by Day of Week'; +PRINT '---------------------------------------------------------------------'; +SELECT + DayOfWeek, + SUM(OrderCount) AS TotalOrders, + CAST(SUM(TotalRevenue) AS DECIMAL(18,2)) AS TotalRevenue, + CAST(AVG(AvgOrderValue) AS DECIMAL(18,2)) AS AvgOrderValue, + SUM(UniqueCustomers) AS TotalCustomers +FROM vw_Sales_TimeBasedAnalysis +GROUP BY DayOfWeek, DayNumber +ORDER BY DayNumber; +GO + +PRINT ''; +PRINT '22. Sales by Time of Day'; +PRINT '---------------------------------------------------------------------'; +SELECT + TimeOfDay, + SUM(OrderCount) AS TotalOrders, + CAST(SUM(TotalRevenue) AS DECIMAL(18,2)) AS TotalRevenue, + CAST(AVG(AvgOrderValue) AS DECIMAL(18,2)) AS AvgOrderValue +FROM vw_Sales_TimeBasedAnalysis +GROUP BY TimeOfDay +ORDER BY TotalOrders DESC; +GO + +-- ============================================= +-- 17. SALES PIPELINE +-- ============================================= + +PRINT ''; +PRINT '23. Sales Pipeline Funnel'; +PRINT '---------------------------------------------------------------------'; +SELECT + Stage, + Count, + CAST(Value AS DECIMAL(18,2)) AS Value, + PriorStageCount, + CAST(PriorStageValue AS DECIMAL(18,2)) AS PriorStageValue, + ConversionRate AS [Conversion %], + ValueRetentionRate AS [Value Retention %] +FROM vw_Sales_Pipeline +ORDER BY StageOrder; +GO + +-- ============================================= +-- 18. CUSTOMER SEGMENTATION +-- ============================================= + +PRINT ''; +PRINT '24. Customer Segmentation - RFM Analysis'; +PRINT '---------------------------------------------------------------------'; +SELECT + CustomerSegment, + COUNT(*) AS CustomerCount, + CAST(AVG(Monetary) AS DECIMAL(18,2)) AS AvgSpend, + CAST(AVG(Frequency) AS DECIMAL(10,1)) AS AvgOrders, + CAST(AVG(Recency) AS DECIMAL(10,1)) AS AvgDaysSinceOrder, + CAST(SUM(Monetary) AS DECIMAL(18,2)) AS TotalRevenue +FROM vw_Sales_CustomerSegmentation +GROUP BY CustomerSegment +ORDER BY TotalRevenue DESC; +GO + +PRINT ''; +PRINT '25. Champions and At-Risk Customers'; +PRINT '---------------------------------------------------------------------'; +SELECT + CustomerName, + CustomerType, + Frequency AS Orders, + CAST(Monetary AS DECIMAL(18,2)) AS TotalSpend, + Recency AS DaysSinceLastOrder, + CustomerSegment, + RecommendedAction +FROM vw_Sales_CustomerSegmentation +WHERE CustomerSegment IN ('Champions', 'At Risk', 'Lost') +ORDER BY CustomerSegment, Monetary DESC; +GO + +-- ============================================= +-- 19. ADVANCED ANALYTICS +-- ============================================= + +PRINT ''; +PRINT '26. Cross-Sell Opportunities - Popular Product Combinations'; +PRINT '---------------------------------------------------------------------'; +SELECT TOP 10 + ProductMix, + OrderCount, + CAST(TotalRevenue AS DECIMAL(18,2)) AS Revenue, + ChannelName, + PopularityRank +FROM vw_Sales_ProductMixAnalysis +WHERE UniqueProducts > 1 +ORDER BY OrderCount DESC; +GO + +PRINT ''; +PRINT '27. Sales KPI Dashboard - Current Month vs Prior Month'; +PRINT '---------------------------------------------------------------------'; +WITH CurrentMonth AS ( + SELECT + SUM(OrderCount) AS Orders, + SUM(UniqueCustomers) AS Customers, + SUM(NetSales) AS Revenue + FROM vw_Sales_ByChannel + WHERE Year = YEAR(GETDATE()) AND Month = MONTH(GETDATE()) +), +PriorMonth AS ( + SELECT + SUM(OrderCount) AS Orders, + SUM(UniqueCustomers) AS Customers, + SUM(NetSales) AS Revenue + FROM vw_Sales_ByChannel + WHERE Year = YEAR(DATEADD(MONTH, -1, GETDATE())) + AND Month = MONTH(DATEADD(MONTH, -1, GETDATE())) +) +SELECT + 'Orders' AS Metric, + cm.Orders AS CurrentMonth, + pm.Orders AS PriorMonth, + cm.Orders - pm.Orders AS Change, + CAST((cm.Orders - pm.Orders) * 100.0 / NULLIF(pm.Orders, 0) AS DECIMAL(5,2)) AS [Change %] +FROM CurrentMonth cm, PriorMonth pm + +UNION ALL + +SELECT + 'Customers', + cm.Customers, + pm.Customers, + cm.Customers - pm.Customers, + CAST((cm.Customers - pm.Customers) * 100.0 / NULLIF(pm.Customers, 0) AS DECIMAL(5,2)) +FROM CurrentMonth cm, PriorMonth pm + +UNION ALL + +SELECT + 'Revenue', + CAST(cm.Revenue AS INT), + CAST(pm.Revenue AS INT), + CAST(cm.Revenue - pm.Revenue AS INT), + CAST((cm.Revenue - pm.Revenue) * 100.0 / NULLIF(pm.Revenue, 0) AS DECIMAL(5,2)) +FROM CurrentMonth cm, PriorMonth pm; +GO + +PRINT ''; +PRINT '28. Channel Performance Summary - All Time'; +PRINT '---------------------------------------------------------------------'; +SELECT + ChannelName, + SUM(OrderCount) AS TotalOrders, + SUM(UniqueCustomers) AS TotalCustomers, + CAST(SUM(NetSales) AS DECIMAL(18,2)) AS TotalRevenue, + CAST(AVG(AvgOrderValue) AS DECIMAL(18,2)) AS AvgOrderValue, + CAST(AVG(AvgDiscountPercent) AS DECIMAL(5,2)) AS [Avg Discount %] +FROM vw_Sales_ByChannel +GROUP BY ChannelName +ORDER BY TotalRevenue DESC; +GO + +PRINT ''; +PRINT '============================================='; +PRINT 'Sales operations sample queries completed!'; +PRINT 'Use these as templates for your sales analysis.'; +PRINT '============================================='; +GO diff --git a/samples/databases/futon-manufacturing/README.md b/samples/databases/futon-manufacturing/README.md new file mode 100644 index 0000000000..6e9c3b3938 --- /dev/null +++ b/samples/databases/futon-manufacturing/README.md @@ -0,0 +1,594 @@ +# Futon Manufacturing Database + +A comprehensive SQL Server database designed for a futon manufacturing business with multi-level bill of materials (BOM), inventory management, production tracking, and sales operations. + +## Overview + +This database manages the complete manufacturing lifecycle for a futon manufacturer that produces finished futons from raw materials (fill, fabric, frames) through intermediate components (pillows, mattresses, frames) to finished goods. + +## Features + +- **Multi-Level Bill of Materials (BOM)**: Supports complex product structures with multiple levels of assembly +- **Inventory Management**: Track inventory across multiple warehouses with transaction history +- **Production Management**: Work orders, production completions, and work center capacity tracking +- **Quality Control**: Inspection tracking for incoming, in-process, and final products +- **Purchasing**: Purchase orders and supplier management +- **Sales Operations**: Multi-channel sales (Retail, Online, Wholesale) with complete order lifecycle +- **20 Manufacturing Reports**: Comprehensive reporting views for operational insights +- **20 Sales Operations Reports**: Complete sales analytics and performance metrics + +## Database Structure + +### Core Tables + +#### Reference Tables +- `UnitOfMeasure` - Units like EA (Each), YD (Yard), LB (Pound) +- `ItemType` - Raw Material, Component, Finished Good +- `TransactionType` - Types of inventory transactions + +#### Master Data +- `Items` - All products (raw materials, components, finished goods) +- `BillOfMaterials` - Multi-level product structure +- `Warehouse` - Storage locations +- `WorkCenter` - Production work centers +- `Supplier` - Vendor information +- `Customer` - Customer information + +#### Operational Tables +- `Inventory` - Current inventory levels by warehouse +- `InventoryTransaction` - All inventory movements +- `PurchaseOrder` / `PurchaseOrderDetail` - Purchasing +- `ProductionOrder` / `ProductionOrderMaterial` / `ProductionCompletion` - Production +- `SalesOrder` / `SalesOrderDetail` - Sales +- `QualityInspection` - Quality control records + +#### Sales Operations Tables +- `SalesChannel` - Sales channels (Retail, Online, Wholesale) +- `Store` - Retail store locations +- `SalesTerritory` - Geographic sales territories +- `SalesRep` - Sales representatives +- `SalesReturn` / `SalesReturnDetail` - Product returns and refunds +- `SalesQuote` / `SalesQuoteDetail` - Sales quotations +- `Promotion` - Promotional campaigns +- `PriceList` / `PriceListDetail` - Channel-specific pricing + +## Product Hierarchy + +The database includes three levels of products: + +### Level 1: Raw Materials +- **Fill Materials**: Polyester fiber, memory foam, cotton, latex foam, down alternative +- **Fabrics**: Canvas (various colors), microfiber suede, linen blend, twill, velvet +- **Frame Materials**: Pine rails, hardwood slats, steel brackets, hinges, hardware +- **Finishes**: Wood stains and polyurethane + +### Level 2: Components +- **Pillows**: Various fills and fabric combinations +- **Mattresses**: Twin, Full, Queen sizes with different fill types +- **Frames**: Different sizes and finishes (Natural Oak, Dark Walnut) + +### Level 3: Finished Goods +- Complete futons combining mattress, frame, and pillows +- Multiple configurations from economy to luxury models +- Sizes: Twin, Full, Queen + +## Installation + +Run the SQL scripts in order: + +```sql +-- 1. Create database and schema +:r 01-schema.sql + +-- 2. Insert sample data +:r 02-sample-data.sql + +-- 3. Create manufacturing reports +:r 03-manufacturing-reports.sql + +-- 4. (Optional) Run manufacturing sample queries +:r 04-sample-queries.sql + +-- 5. Enhance schema for sales operations +:r 05-sales-schema-enhancements.sql + +-- 6. Insert sales sample data +:r 06-sales-sample-data.sql + +-- 7. Create sales operations reports +:r 07-sales-reports.sql + +-- 8. (Optional) Run sales sample queries +:r 08-sales-sample-queries.sql +``` + +## Manufacturing Reports + +### 1. Multi-Level BOM Explosion (`vw_BOMExplosion`) +Shows complete material requirements for any item, recursively expanding through all levels. + +```sql +-- Get all materials needed to build a specific futon +SELECT * FROM vw_BOMExplosion +WHERE ParentItemCode = 'FG-FUT-001' +ORDER BY Level, ComponentItemCode; +``` + +### 2. Where-Used Report (`vw_WhereUsed`) +Shows where each component is used throughout the product structure. + +```sql +-- Find all products that use a specific fabric +SELECT * FROM vw_WhereUsed +WHERE ComponentItemCode = 'RM-FAB-001'; +``` + +### 3. Inventory Valuation (`vw_InventoryValuation`) +Current inventory value by warehouse and item type. + +```sql +-- Total inventory value by type +SELECT ItemType, SUM(InventoryValue) AS TotalValue +FROM vw_InventoryValuation +GROUP BY ItemType; +``` + +### 4. Items Below Reorder Point (`vw_ItemsBelowReorderPoint`) +Items that need to be reordered with supplier information. + +```sql +-- Get critical shortages +SELECT * FROM vw_ItemsBelowReorderPoint +ORDER BY ShortageQuantity DESC; +``` + +### 5. Production Order Status (`vw_ProductionOrderStatus`) +Track production orders with completion percentages and schedule status. + +```sql +-- Active production orders with status +SELECT * FROM vw_ProductionOrderStatus +WHERE Status IN ('Planned', 'Released', 'InProgress') +ORDER BY PlannedCompletionDate; +``` + +### 6. Material Requirements Planning - MRP (`vw_MaterialRequirements`) +Calculate material needs for all open production orders. + +```sql +-- Material shortages for production +SELECT * FROM vw_MaterialRequirements +WHERE AvailabilityStatus IN ('Out of Stock', 'Partial') +ORDER BY PlannedCompletionDate; +``` + +### 7. Work Center Capacity Analysis (`vw_WorkCenterCapacity`) +Analyze workload and capacity by work center. + +```sql +-- Work center utilization +SELECT * FROM vw_WorkCenterCapacity +ORDER BY DaysOfWork DESC; +``` + +### 8. Production Completion Summary (`vw_ProductionCompletionSummary`) +Production output and scrap rates by period. + +```sql +-- Monthly production summary +SELECT Year, Month, SUM(TotalCompleted) AS Units, AVG(ScrapRate) AS AvgScrapRate +FROM vw_ProductionCompletionSummary +GROUP BY Year, Month; +``` + +### 9. Quality Inspection Summary (`vw_QualityInspectionSummary`) +Quality metrics and acceptance rates. + +```sql +-- Quality performance by inspection type +SELECT InspectionType, AVG(AcceptanceRate) AS AvgAcceptanceRate +FROM vw_QualityInspectionSummary +GROUP BY InspectionType; +``` + +### 10. Supplier Performance (`vw_SupplierPerformance`) +Evaluate supplier delivery performance and ratings. + +```sql +-- Top performing suppliers +SELECT * FROM vw_SupplierPerformance +ORDER BY OnTimeDeliveryRate DESC; +``` + +### 11. Purchase Order Status (`vw_PurchaseOrderStatus`) +Track purchase orders and receiving progress. + +```sql +-- Overdue purchase orders +SELECT * FROM vw_PurchaseOrderStatus +WHERE DeliveryStatus = 'Overdue'; +``` + +### 12. Sales Order Backlog (`vw_SalesOrderBacklog`) +Monitor unfulfilled customer orders. + +```sql +-- Orders due soon +SELECT * FROM vw_SalesOrderBacklog +WHERE FulfillmentStatus = 'Due Soon' +ORDER BY RequestedDeliveryDate; +``` + +### 13. Cost Roll-Up (`vw_CostRollUp`) +Item costs with material cost breakdown and profit margins. + +```sql +-- Profit analysis for finished goods +SELECT * FROM vw_CostRollUp +WHERE ItemType = 'Finished Goods' +ORDER BY GrossMarginPercent DESC; +``` + +### 14. Inventory Turnover Analysis (`vw_InventoryTurnover`) +Analyze inventory movement and identify slow-moving items. + +```sql +-- Slow and non-moving inventory +SELECT * FROM vw_InventoryTurnover +WHERE MovementClass IN ('Slow Moving', 'Non-Moving'); +``` + +### 15. Late Production Orders (`vw_LateProductionOrders`) +Production orders past their due date. + +```sql +-- Critical late orders +SELECT * FROM vw_LateProductionOrders +WHERE LatenessSeverity IN ('Critical', 'High') +ORDER BY DaysLate DESC; +``` + +### 16. Component Shortage Report (`vw_ComponentShortage`) +Identify component shortages affecting production. + +```sql +-- Urgent shortages +SELECT * FROM vw_ComponentShortage +WHERE UrgencyLevel = 'Urgent' +ORDER BY DaysUntilNeeded; +``` + +### 17. Daily Production Schedule (`vw_DailyProductionSchedule`) +2-week production schedule by work center. + +```sql +-- This week's production schedule +SELECT * FROM vw_DailyProductionSchedule +WHERE ScheduledDate BETWEEN CAST(GETDATE() AS DATE) AND DATEADD(DAY, 7, CAST(GETDATE() AS DATE)) +ORDER BY ScheduledDate, Priority; +``` + +### 18. Scrap and Waste Analysis (`vw_ScrapWasteAnalysis`) +Track scrap rates and waste costs. + +```sql +-- High scrap items +SELECT * FROM vw_ScrapWasteAnalysis +WHERE ScrapLevel = 'High' +ORDER BY ScrapValue DESC; +``` + +### 19. Customer Order Fulfillment Rate (`vw_CustomerFulfillmentRate`) +Customer service levels and on-time delivery. + +```sql +-- Customer service performance +SELECT * FROM vw_CustomerFulfillmentRate +ORDER BY TotalOrderValue DESC; +``` + +### 20. Raw Material Usage by Period (`vw_RawMaterialUsage`) +Material consumption trends over time. + +```sql +-- Monthly material usage trends +SELECT Year, Month, ItemName, SUM(TotalUsage) AS Usage +FROM vw_RawMaterialUsage +GROUP BY Year, Month, ItemName +ORDER BY Year, Month, ItemName; +``` + +## Sales Operations Reports + +### 1. Sales Performance by Channel (`vw_Sales_ByChannel`) +Analyze sales across Retail, Online, and Wholesale channels with trends. + +```sql +-- Monthly sales by channel +SELECT * FROM vw_Sales_ByChannel +WHERE Year = 2024 +ORDER BY Year, Month, NetSales DESC; +``` + +### 2. Store Performance Report (`vw_Sales_StorePerformance`) +Track individual retail store performance metrics. + +```sql +-- Top performing stores +SELECT * FROM vw_Sales_StorePerformance +ORDER BY NetSales DESC; +``` + +### 3. Sales Representative Performance (`vw_Sales_RepPerformance`) +Evaluate sales rep performance, territories, and quote conversion. + +```sql +-- Sales rep leaderboard +SELECT * FROM vw_Sales_RepPerformance +ORDER BY SalesRank; +``` + +### 4. Customer Sales Analysis (`vw_Sales_CustomerAnalysis`) +Comprehensive customer metrics with segmentation and status. + +```sql +-- High-value customers +SELECT * FROM vw_Sales_CustomerAnalysis +WHERE CustomerSegment IN ('VIP', 'Regular') +ORDER BY TotalSales DESC; +``` + +### 5. Product Sales Performance (`vw_Sales_ProductPerformance`) +Product-level sales metrics by channel with profitability. + +```sql +-- Best selling products +SELECT * FROM vw_Sales_ProductPerformance +ORDER BY TotalUnitsSold DESC; +``` + +### 6. Sales Trend Analysis (`vw_Sales_TrendAnalysis`) +Month-over-month and year-over-year growth analysis. + +```sql +-- Recent trends with growth rates +SELECT * FROM vw_Sales_TrendAnalysis +ORDER BY Year DESC, Month DESC; +``` + +### 7. Average Order Value Analysis (`vw_Sales_OrderValueAnalysis`) +Order size distribution and metrics by channel and customer type. + +```sql +-- AOV by channel +SELECT ChannelName, AvgNetAmount, OrderCount +FROM vw_Sales_OrderValueAnalysis +GROUP BY ChannelName, AvgNetAmount, OrderCount; +``` + +### 8. Sales Returns Analysis (`vw_Sales_ReturnsAnalysis`) +Track returns by reason, product, and channel. + +```sql +-- Top return reasons +SELECT ReasonDescription, SUM(TotalRefunds) AS Refunds +FROM vw_Sales_ReturnsAnalysis +GROUP BY ReasonDescription +ORDER BY Refunds DESC; +``` + +### 9. Sales Quote Conversion Analysis (`vw_Sales_QuoteConversion`) +Monitor quote-to-order conversion rates and pipeline. + +```sql +-- Quote conversion rates +SELECT ConversionStatus, COUNT(*) AS Quotes, AVG(QuoteAmount) AS AvgValue +FROM vw_Sales_QuoteConversion +GROUP BY ConversionStatus; +``` + +### 10. Discount Analysis Report (`vw_Sales_DiscountAnalysis`) +Analyze discount impact on margins and profitability. + +```sql +-- Discount effectiveness by channel +SELECT * FROM vw_Sales_DiscountAnalysis +ORDER BY Year DESC, Month DESC; +``` + +### 11. Top Selling Products Report (`vw_Sales_TopProducts`) +Ranked product performance by channel and time period. + +```sql +-- Top 10 products this month +SELECT * FROM vw_Sales_TopProducts +WHERE Year = YEAR(GETDATE()) AND Month = MONTH(GETDATE()) + AND UnitRank <= 10 +ORDER BY ChannelName, UnitRank; +``` + +### 12. Sales by Territory Report (`vw_Sales_ByTerritory`) +Geographic territory performance and sales rep efficiency. + +```sql +-- Territory comparison +SELECT * FROM vw_Sales_ByTerritory +ORDER BY NetSales DESC; +``` + +### 13. Channel Profitability Analysis (`vw_Sales_ChannelProfitability`) +Full profitability analysis including COGS and margins by channel. + +```sql +-- Most profitable channels +SELECT * FROM vw_Sales_ChannelProfitability +ORDER BY GrossProfit DESC; +``` + +### 14. Customer Lifetime Value (`vw_Sales_CustomerLifetimeValue`) +Calculate CLV with customer tiers and activity status. + +```sql +-- Top customers by lifetime value +SELECT * FROM vw_Sales_CustomerLifetimeValue +WHERE CustomerTier IN ('Platinum', 'Gold') +ORDER BY TotalRevenue DESC; +``` + +### 15. Sales Growth Analysis (`vw_Sales_GrowthAnalysis`) +Track revenue and customer growth by channel over time. + +```sql +-- Recent growth trends +SELECT * FROM vw_Sales_GrowthAnalysis +WHERE Year >= YEAR(DATEADD(MONTH, -6, GETDATE())) +ORDER BY Year DESC, Month DESC; +``` + +### 16. Order Size Distribution (`vw_Sales_OrderSizeDistribution`) +Analyze order patterns and basket sizes. + +```sql +-- Order size breakdown +SELECT * FROM vw_Sales_OrderSizeDistribution +ORDER BY ChannelName, OrderSizeBucket; +``` + +### 17. Product Mix Analysis (`vw_Sales_ProductMixAnalysis`) +Identify popular product combinations and cross-sell opportunities. + +```sql +-- Most common product combinations +SELECT * FROM vw_Sales_ProductMixAnalysis +WHERE UniqueProducts > 1 +ORDER BY OrderCount DESC; +``` + +### 18. Day of Week / Time-Based Analysis (`vw_Sales_TimeBasedAnalysis`) +Understand sales patterns by day of week and time of day. + +```sql +-- Sales by day of week +SELECT DayOfWeek, SUM(OrderCount) AS Orders, SUM(TotalRevenue) AS Revenue +FROM vw_Sales_TimeBasedAnalysis +GROUP BY DayOfWeek, DayNumber +ORDER BY DayNumber; +``` + +### 19. Sales Pipeline (`vw_Sales_Pipeline`) +Track conversion rates from quotes through delivery. + +```sql +-- Pipeline funnel analysis +SELECT * FROM vw_Sales_Pipeline +ORDER BY StageOrder; +``` + +### 20. Customer Segmentation RFM Analysis (`vw_Sales_CustomerSegmentation`) +RFM (Recency, Frequency, Monetary) segmentation with actionable recommendations. + +```sql +-- Customer segments with recommended actions +SELECT CustomerSegment, COUNT(*) AS Customers, SUM(Monetary) AS TotalValue +FROM vw_Sales_CustomerSegmentation +GROUP BY CustomerSegment +ORDER BY TotalValue DESC; +``` + +## Sample Data + +The database includes sample data for: +- 21 Raw materials (fills, fabrics, wood, metal, hardware, finishes) +- 15 Components (5 pillow types, 5 mattress types, 5 frame types) +- 6 Finished futon products +- 5 Suppliers with pricing +- 8 Customers with various types (Retail, Wholesale, Online) +- 3 Warehouses +- 5 Work centers +- Initial inventory levels +- 7 Retail stores across multiple cities +- 6 Sales territories with regions +- 8 Sales representatives +- 15 Sales orders across all channels (Retail, Online, Wholesale) +- Sales returns and quotations +- Promotions and price lists by channel + +## Use Cases + +### Manufacturing Operations +1. **BOM Management**: Maintain multi-level product structures +2. **Production Planning**: Schedule work orders based on capacity +3. **Material Planning**: Calculate material requirements (MRP) +4. **Inventory Control**: Track materials, WIP, and finished goods +5. **Quality Management**: Monitor inspection results and defect rates + +### Cost Accounting +1. **Cost Roll-Up**: Calculate product costs from component costs +2. **Scrap Analysis**: Track waste and its financial impact +3. **Inventory Valuation**: Value inventory using standard costs +4. **Variance Analysis**: Compare actual vs. standard costs + +### Supply Chain +1. **Supplier Management**: Track supplier performance +2. **Purchase Planning**: Identify reorder needs +3. **Receiving**: Process incoming materials +4. **Lead Time Management**: Monitor delivery performance + +### Sales & Distribution +1. **Order Management**: Process customer orders +2. **Fulfillment**: Track order completion and shipping +3. **Customer Service**: Monitor service levels +4. **Backlog Management**: Manage unfulfilled orders + +## Schema Highlights + +### Multi-Level BOM +The `BillOfMaterials` table supports unlimited BOM levels with: +- Recursive relationships +- Scrap rate tracking +- Effective dating +- Unit of measure flexibility + +### Inventory Tracking +- Real-time available quantity (on-hand minus allocated) +- Complete transaction history +- Multi-warehouse support +- Cycle counting capabilities + +### Production Control +- Work order management +- Material issue tracking +- Production completion recording +- Work center capacity planning + +## Performance Considerations + +The database includes indexes on: +- BOM parent and component lookups +- Inventory transactions by item and date +- Order status and date ranges +- Item type filtering + +## Future Enhancements + +Potential areas for expansion: +- Routing (labor operations per work center) +- Shop floor data collection +- Advanced planning and scheduling +- Lot/serial number tracking +- Multi-currency support +- Cost variance tracking +- Demand forecasting + +## License + +This sample database is provided as-is for educational and demonstration purposes. + +## Author + +Created as part of SQL Server Samples repository for demonstrating manufacturing database design patterns. + +## Version History + +- 2.0.0 - Added sales operations with 20 sales reports, multi-channel support, returns, quotes, and territories +- 1.0.0 - Initial release with complete schema, sample data, and 20 manufacturing reports diff --git a/samples/databases/northwind-pubs/instpubs.sql b/samples/databases/northwind-pubs/instpubs.sql index 76401b7adf..d887fd48cb 100644 --- a/samples/databases/northwind-pubs/instpubs.sql +++ b/samples/databases/northwind-pubs/instpubs.sql @@ -68,236 +68,129 @@ execute sp_addtype empid ,'char(9)' ,'NOT NULL' raiserror('Now at the create table section ....',0,1) GO - -CREATE TABLE authors -( - au_id id - - CHECK (au_id like '[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]') - - CONSTRAINT UPKCL_auidind PRIMARY KEY CLUSTERED, - - au_lname varchar(40) NOT NULL, - au_fname varchar(20) NOT NULL, - - phone char(12) NOT NULL - - DEFAULT ('UNKNOWN'), - - address varchar(40) NULL, - city varchar(20) NULL, - state char(2) NULL, - - zip char(5) NULL - - CHECK (zip like '[0-9][0-9][0-9][0-9][0-9]'), - - contract bit NOT NULL -) +CREATE TABLE authors ( + au_id CHAR(11) CHECK (au_id LIKE '[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]') + CONSTRAINT UPKCL_auidind PRIMARY KEY CLUSTERED, + au_lname VARCHAR(40) NOT NULL, + au_fname VARCHAR(20) NOT NULL, + phone CHAR(12) NOT NULL DEFAULT ('UNKNOWN'), + address VARCHAR(40) NULL, + city VARCHAR(20) NULL, + state CHAR(2) NULL, + zip CHAR(5) NULL CHECK (zip LIKE '[0-9][0-9][0-9][0-9][0-9]'), + contract BIT NOT NULL +); GO -CREATE TABLE publishers -( - pub_id char(4) NOT NULL - - CONSTRAINT UPKCL_pubind PRIMARY KEY CLUSTERED - - CHECK (pub_id in ('1389', '0736', '0877', '1622', '1756') - OR pub_id like '99[0-9][0-9]'), - - pub_name varchar(40) NULL, - city varchar(20) NULL, - state char(2) NULL, - - country varchar(30) NULL - - DEFAULT('USA') -) +CREATE TABLE publishers ( + pub_id CHAR(4) NOT NULL + CONSTRAINT UPKCL_pubind PRIMARY KEY CLUSTERED + CHECK (pub_id IN ('1389', '0736', '0877', '1622', '1756') OR pub_id LIKE '99[0-9][0-9]'), + pub_name VARCHAR(40) NULL, + city VARCHAR(20) NULL, + state CHAR(2) NULL, + country VARCHAR(30) NULL DEFAULT('USA') +); GO -CREATE TABLE titles -( - title_id tid - - CONSTRAINT UPKCL_titleidind PRIMARY KEY CLUSTERED, - - title varchar(80) NOT NULL, - - type char(12) NOT NULL - - DEFAULT ('UNDECIDED'), - - pub_id char(4) NULL - - REFERENCES publishers(pub_id), - - price money NULL, - advance money NULL, - royalty int NULL, - ytd_sales int NULL, - notes varchar(200) NULL, - - pubdate datetime NOT NULL - - DEFAULT (getdate()) -) +CREATE TABLE titles ( + title_id CHAR(6) CONSTRAINT UPKCL_titleidind PRIMARY KEY CLUSTERED, + title VARCHAR(80) NOT NULL, + type CHAR(12) NOT NULL DEFAULT ('UNDECIDED'), + pub_id CHAR(4) NULL REFERENCES publishers(pub_id), + price MONEY NULL, + advance MONEY NULL, + royalty INT NULL, + ytd_sales INT NULL, + notes VARCHAR(200) NULL, + pubdate DATETIME NOT NULL DEFAULT (GETDATE()) +); GO -CREATE TABLE titleauthor -( - au_id id - - REFERENCES authors(au_id), - - title_id tid - - REFERENCES titles(title_id), - - au_ord tinyint NULL, - royaltyper int NULL, - - - CONSTRAINT UPKCL_taind PRIMARY KEY CLUSTERED(au_id, title_id) -) +CREATE TABLE titleauthor ( + au_id CHAR(11) REFERENCES authors(au_id), + title_id CHAR(6) REFERENCES titles(title_id), + au_ord TINYINT NULL, + royaltyper INT NULL, + CONSTRAINT UPKCL_taind PRIMARY KEY CLUSTERED(au_id, title_id) +); GO -CREATE TABLE stores -( - stor_id char(4) NOT NULL - - CONSTRAINT UPK_storeid PRIMARY KEY CLUSTERED, - - stor_name varchar(40) NULL, - stor_address varchar(40) NULL, - city varchar(20) NULL, - state char(2) NULL, - zip char(5) NULL -) +CREATE TABLE stores ( + stor_id CHAR(4) NOT NULL CONSTRAINT UPK_storeid PRIMARY KEY CLUSTERED, + stor_name VARCHAR(40) NULL, + stor_address VARCHAR(40) NULL, + city VARCHAR(20) NULL, + state CHAR(2) NULL, + zip CHAR(5) NULL +); GO -CREATE TABLE sales -( - stor_id char(4) NOT NULL - - REFERENCES stores(stor_id), - - ord_num varchar(20) NOT NULL, - ord_date datetime NOT NULL, - qty smallint NOT NULL, - payterms varchar(12) NOT NULL, - - title_id tid - - REFERENCES titles(title_id), - - - CONSTRAINT UPKCL_sales PRIMARY KEY CLUSTERED (stor_id, ord_num, title_id) -) +CREATE TABLE sales ( + stor_id CHAR(4) NOT NULL REFERENCES stores(stor_id), + ord_num VARCHAR(20) NOT NULL, + ord_date DATETIME NOT NULL, + qty SMALLINT NOT NULL, + payterms VARCHAR(12) NOT NULL, + title_id CHAR(6) REFERENCES titles(title_id), + CONSTRAINT UPKCL_sales PRIMARY KEY CLUSTERED (stor_id, ord_num, title_id) +); GO -CREATE TABLE roysched -( - title_id tid - - REFERENCES titles(title_id), - - lorange int NULL, - hirange int NULL, - royalty int NULL -) +CREATE TABLE roysched ( + title_id CHAR(6) REFERENCES titles(title_id), + lorange INT NULL, + hirange INT NULL, + royalty INT NULL +); GO -CREATE TABLE discounts -( - discounttype varchar(40) NOT NULL, - - stor_id char(4) NULL - - REFERENCES stores(stor_id), - - lowqty smallint NULL, - highqty smallint NULL, - discount dec(4,2) NOT NULL -) +CREATE TABLE discounts ( + discounttype VARCHAR(40) NOT NULL, + stor_id CHAR(4) NULL REFERENCES stores(stor_id), + lowqty SMALLINT NULL, + highqty SMALLINT NULL, + discount DECIMAL(4,2) NOT NULL +); GO -CREATE TABLE jobs -( - job_id smallint IDENTITY(1,1) - - PRIMARY KEY CLUSTERED, - - job_desc varchar(50) NOT NULL - - DEFAULT 'New Position - title not formalized yet', - - min_lvl tinyint NOT NULL - - CHECK (min_lvl >= 10), - - max_lvl tinyint NOT NULL - - CHECK (max_lvl <= 250) -) +CREATE TABLE jobs ( + job_id SMALLINT IDENTITY(1,1) PRIMARY KEY CLUSTERED, + job_desc VARCHAR(50) NOT NULL DEFAULT 'New Position - title not formalized yet', + min_lvl TINYINT NOT NULL CHECK (min_lvl >= 10), + max_lvl TINYINT NOT NULL CHECK (max_lvl <= 250) +); GO -CREATE TABLE pub_info -( - pub_id char(4) NOT NULL - - REFERENCES publishers(pub_id) - - CONSTRAINT UPKCL_pubinfo PRIMARY KEY CLUSTERED, - - logo image NULL, - pr_info text NULL -) +CREATE TABLE pub_info ( + pub_id CHAR(4) NOT NULL REFERENCES publishers(pub_id) CONSTRAINT UPKCL_pubinfo PRIMARY KEY CLUSTERED, + logo IMAGE NULL, + pr_info TEXT NULL +); GO -CREATE TABLE employee -( - emp_id empid - - CONSTRAINT PK_emp_id PRIMARY KEY NONCLUSTERED - - CONSTRAINT CK_emp_id CHECK (emp_id LIKE - '[A-Z][A-Z][A-Z][1-9][0-9][0-9][0-9][0-9][FM]' or - emp_id LIKE '[A-Z]-[A-Z][1-9][0-9][0-9][0-9][0-9][FM]'), - - fname varchar(20) NOT NULL, - minit char(1) NULL, - lname varchar(30) NOT NULL, - - job_id smallint NOT NULL - - DEFAULT 1 - - REFERENCES jobs(job_id), - - job_lvl tinyint - - DEFAULT 10, - - pub_id char(4) NOT NULL - - DEFAULT ('9952') - - REFERENCES publishers(pub_id), - - hire_date datetime NOT NULL - - DEFAULT (getdate()) -) +CREATE TABLE employee ( + emp_id CHAR(10) CONSTRAINT PK_emp_id PRIMARY KEY NONCLUSTERED CHECK ( + emp_id LIKE '[A-Z][A-Z][A-Z][1-9][0-9][0-9][0-9][0-9][FM]' OR + emp_id LIKE '[A-Z]-[A-Z][1-9][0-9][0-9][0-9][0-9][FM]'), + fname VARCHAR(20) NOT NULL, + minit CHAR(1) NULL, + lname VARCHAR(30) NOT NULL, + job_id SMALLINT NOT NULL DEFAULT 1 REFERENCES jobs(job_id), + job_lvl TINYINT DEFAULT 10, + pub_id CHAR(4) NOT NULL DEFAULT ('9952') REFERENCES publishers(pub_id), + hire_date DATETIME NOT NULL DEFAULT (GETDATE()) +); GO diff --git a/samples/databases/wide-world-importers/wwi-app/wwi-app.csproj b/samples/databases/wide-world-importers/wwi-app/wwi-app.csproj index ef139dc607..ca1749fcfe 100644 --- a/samples/databases/wide-world-importers/wwi-app/wwi-app.csproj +++ b/samples/databases/wide-world-importers/wwi-app/wwi-app.csproj @@ -14,15 +14,9 @@ - - - - - - + - - + diff --git a/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/o.js/package-lock.json b/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/o.js/package-lock.json index 0f5a81185f..4fc8c7af39 100644 --- a/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/o.js/package-lock.json +++ b/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/o.js/package-lock.json @@ -1,438 +1,534 @@ { "name": "o.js", "version": "0.3.7", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "abbrev": { + "packages": { + "": { + "version": "0.3.7", + "license": "MIT", + "dependencies": { + "q": "^1.5.0", + "xhr2": "^0.1.4 " + }, + "devDependencies": { + "jasmine": "*", + "jslint": "^0.10.3", + "uglify-js": "*" + } + }, + "node_modules/abbrev": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz", "integrity": "sha1-0FVMIlZjbi9W58LlrRg/hZQo2B8=", "dev": true }, - "balanced-match": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", - "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", - "dev": true + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" }, - "brace-expansion": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz", - "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=", + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, - "requires": { - "balanced-match": "0.4.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "buffer-shims": { + "node_modules/buffer-shims": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=", "dev": true }, - "concat-map": { + "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "core-util-is": { + "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, - "exit": { + "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true + "dev": true, + "engines": { + "node": ">= 0.8.0" + } }, - "fs.realpath": { + "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "glob": { + "node_modules/glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" } }, - "inflight": { + "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true }, - "isarray": { + "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, - "jasmine": { + "node_modules/jasmine": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-4.5.0.tgz", "integrity": "sha512-9olGRvNZyADIwYL9XBNBst5BTU/YaePzuddK+YRslc7rI9MdTIE4r3xaBKbv2GEmzYYUfMOdTR8/i6JfLZaxSQ==", "dev": true, - "requires": { + "dependencies": { "glob": "^7.1.6", "jasmine-core": "^4.5.0" }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - } - } - }, - "jasmine-core": { + "bin": { + "jasmine": "bin/jasmine.js" + } + }, + "node_modules/jasmine-core": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.5.0.tgz", "integrity": "sha512-9PMzyvhtocxb3aXJVOPqBDswdgyAeSB81QnLop4npOpbqnheaTEwPc9ZloQeVswugPManznQBjD8kWDTjlnHuw==", "dev": true }, - "jslint": { + "node_modules/jasmine/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jasmine/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jslint": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/jslint/-/jslint-0.10.3.tgz", "integrity": "sha1-iQ2j55ky7fBsX0tSp7W6ZDaGdDY=", "dev": true, - "requires": { - "exit": "0.1.2", - "glob": "7.1.2", - "nopt": "3.0.6", - "readable-stream": "2.1.5" + "dependencies": { + "exit": "~0.1.2", + "glob": "^7.0.3", + "nopt": "~3.0.1", + "readable-stream": "~2.1.2" + }, + "bin": { + "jslint": "bin/jslint.js" + }, + "engines": { + "node": ">=0.8.0" } }, - "minimatch": { + "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "requires": { - "brace-expansion": "1.1.7" + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "nopt": { + "node_modules/nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", "dev": true, - "requires": { - "abbrev": "1.1.0" + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" } }, - "once": { + "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "requires": { - "wrappy": "1.0.2" + "dependencies": { + "wrappy": "1" } }, - "path-is-absolute": { + "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "process-nextick-args": { + "node_modules/process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", "dev": true }, - "q": { + "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==" + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } }, - "readable-stream": { + "node_modules/readable-stream": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=", "dev": true, - "requires": { - "buffer-shims": "1.0.0", - "core-util-is": "1.0.2", - "inherits": "2.0.3", - "isarray": "1.0.0", - "process-nextick-args": "1.0.7", - "string_decoder": "0.10.31", - "util-deprecate": "1.0.2" + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" } }, - "string_decoder": { + "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true }, - "uglify-js": { + "node_modules/uglify-js": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.6.1.tgz", "integrity": "sha1-7bvhiIujUl3tOnv4NrMLNAXTFhs=", "dev": true, - "requires": { - "async": "0.2.10", - "source-map": "0.5.3", - "uglify-to-browserify": "1.0.2", - "yargs": "3.10.0" + "dependencies": { + "async": "~0.2.6", + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uglify-js/node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=", + "dev": true + }, + "node_modules/uglify-js/node_modules/source-map": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.3.tgz", + "integrity": "sha1-gmdLhacbC+dsPnQW0V6fUlLrO+A=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true + }, + "node_modules/uglify-js/node_modules/yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "dependencies": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "dependencies": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "dependencies": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/align-text": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.3.tgz", + "integrity": "sha1-cts5g4cu7CMTkZyUJqmTpBr+k/c=", + "dev": true, + "dependencies": { + "kind-of": "^2.0.0", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/align-text/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", + "dev": true, + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/align-text/node_modules/kind-of/node_modules/is-buffer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.2.tgz", + "integrity": "sha1-+hImWI+gBIsAXEfk+xuxVV1e3qo=", + "deprecated": "This version of 'is-buffer' is out-of-date. You must update to v1.1.6 or newer", + "dev": true + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/align-text/node_modules/longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/align-text/node_modules/repeat-string": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.2.tgz", + "integrity": "sha1-IQZfcHJ60FOg3V6VesngDHVg2Qo=", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/center-align/node_modules/lazy-cache": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.3.tgz", + "integrity": "sha1-6XdUYY+ciGu5mbL/aceLgkU9ZnQ=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "dependencies": { + "align-text": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align/node_modules/align-text": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.3.tgz", + "integrity": "sha1-cts5g4cu7CMTkZyUJqmTpBr+k/c=", + "dev": true, "dependencies": { - "async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=", - "dev": true - }, - "source-map": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.3.tgz", - "integrity": "sha1-gmdLhacbC+dsPnQW0V6fUlLrO+A=", - "dev": true - }, - "uglify-to-browserify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", - "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", - "dev": true - }, - "yargs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", - "dev": true, - "requires": { - "camelcase": "1.2.1", - "cliui": "2.1.0", - "decamelize": "1.1.2", - "window-size": "0.1.0" - }, - "dependencies": { - "camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", - "dev": true - }, - "cliui": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", - "dev": true, - "requires": { - "center-align": "0.1.3", - "right-align": "0.1.3", - "wordwrap": "0.0.2" - }, - "dependencies": { - "center-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", - "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", - "dev": true, - "requires": { - "align-text": "0.1.3", - "lazy-cache": "1.0.3" - }, - "dependencies": { - "align-text": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.3.tgz", - "integrity": "sha1-cts5g4cu7CMTkZyUJqmTpBr+k/c=", - "dev": true, - "requires": { - "kind-of": "2.0.1", - "longest": "1.0.1", - "repeat-string": "1.5.2" - }, - "dependencies": { - "kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", - "dev": true, - "requires": { - "is-buffer": "1.1.2" - }, - "dependencies": { - "is-buffer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.2.tgz", - "integrity": "sha1-+hImWI+gBIsAXEfk+xuxVV1e3qo=", - "dev": true - } - } - }, - "longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true - }, - "repeat-string": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.2.tgz", - "integrity": "sha1-IQZfcHJ60FOg3V6VesngDHVg2Qo=", - "dev": true - } - } - }, - "lazy-cache": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.3.tgz", - "integrity": "sha1-6XdUYY+ciGu5mbL/aceLgkU9ZnQ=", - "dev": true - } - } - }, - "right-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", - "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", - "dev": true, - "requires": { - "align-text": "0.1.3" - }, - "dependencies": { - "align-text": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.3.tgz", - "integrity": "sha1-cts5g4cu7CMTkZyUJqmTpBr+k/c=", - "dev": true, - "requires": { - "kind-of": "2.0.1", - "longest": "1.0.1", - "repeat-string": "1.5.2" - }, - "dependencies": { - "kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", - "dev": true, - "requires": { - "is-buffer": "1.1.2" - }, - "dependencies": { - "is-buffer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.2.tgz", - "integrity": "sha1-+hImWI+gBIsAXEfk+xuxVV1e3qo=", - "dev": true - } - } - }, - "longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true - }, - "repeat-string": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.2.tgz", - "integrity": "sha1-IQZfcHJ60FOg3V6VesngDHVg2Qo=", - "dev": true - } - } - } - } - }, - "wordwrap": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", - "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", - "dev": true - } - } - }, - "decamelize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.1.2.tgz", - "integrity": "sha1-3Mk3J74gljLpiwJxjvTLeWAjIvI=", - "dev": true, - "requires": { - "escape-string-regexp": "1.0.4" - }, - "dependencies": { - "escape-string-regexp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.4.tgz", - "integrity": "sha1-uF5nm0b3LQP7voo79yWdU1whti8=", - "dev": true - } - } - }, - "window-size": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", - "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", - "dev": true - } - } - } - } - }, - "util-deprecate": { + "kind-of": "^2.0.0", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align/node_modules/align-text/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", + "dev": true, + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align/node_modules/align-text/node_modules/kind-of/node_modules/is-buffer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.2.tgz", + "integrity": "sha1-+hImWI+gBIsAXEfk+xuxVV1e3qo=", + "deprecated": "This version of 'is-buffer' is out-of-date. You must update to v1.1.6 or newer", + "dev": true + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align/node_modules/align-text/node_modules/longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/right-align/node_modules/align-text/node_modules/repeat-string": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.2.tgz", + "integrity": "sha1-IQZfcHJ60FOg3V6VesngDHVg2Qo=", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/cliui/node_modules/wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/decamelize": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.1.2.tgz", + "integrity": "sha1-3Mk3J74gljLpiwJxjvTLeWAjIvI=", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/decamelize/node_modules/escape-string-regexp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.4.tgz", + "integrity": "sha1-uF5nm0b3LQP7voo79yWdU1whti8=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uglify-js/node_modules/yargs/node_modules/window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, - "wrappy": { + "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, - "xhr2": { + "node_modules/xhr2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.1.4.tgz", - "integrity": "sha512-3QGhDryRzTbIDj+waTRvMBe8SyPhW79kz3YnNb+HQt/6LPYQT3zT3Jt0Y8pBofZqQX26x8Ecfv0FXR72uH5VpA==" + "integrity": "sha512-3QGhDryRzTbIDj+waTRvMBe8SyPhW79kz3YnNb+HQt/6LPYQT3zT3Jt0Y8pBofZqQX26x8Ecfv0FXR72uH5VpA==", + "engines": { + "node": ">= 0.6" + } } } } diff --git a/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/webcomponentsjs/package-lock.json b/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/webcomponentsjs/package-lock.json index 69bc61674d..d2dc1221c4 100644 --- a/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/webcomponentsjs/package-lock.json +++ b/samples/databases/wide-world-importers/wwi-app/wwwroot/lib/webcomponentsjs/package-lock.json @@ -1652,41 +1652,41 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -3841,6 +3841,19 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -4025,10 +4038,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", - "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4062,9 +4076,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", - "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -4074,13 +4088,19 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001400", - "electron-to-chromium": "^1.4.251", - "node-releases": "^2.0.6", - "update-browserslist-db": "^1.0.9" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4365,9 +4385,9 @@ "dev": true }, "node_modules/caniuse-lite": { - "version": "1.0.30001442", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001442.tgz", - "integrity": "sha512-239m03Pqy0hwxYPYR5JwOIxRJfLTWtle9FV8zosfV5pHg+/51uD4nxcUlM8+mWWGfwKtt8lJNHnD3cWw9VZ6ow==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4377,8 +4397,13 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/capture-stack-trace": { "version": "1.0.2", @@ -6044,10 +6069,11 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.284", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz", - "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", - "dev": true + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "dev": true, + "license": "ISC" }, "node_modules/emitter-component": { "version": "1.1.1", @@ -6306,10 +6332,11 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -7918,9 +7945,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -7928,6 +7955,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -9870,10 +9898,20 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11400,10 +11438,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz", - "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==", - "dev": true + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nomnom": { "version": "1.8.1", @@ -12216,10 +12258,11 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/pify": { "version": "3.0.0", @@ -16622,9 +16665,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", - "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -16634,14 +16677,19 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { - "browserslist-lint": "cli.js" + "update-browserslist-db": "cli.js" }, "peerDependencies": { "browserslist": ">= 4.21.0" diff --git a/samples/databases/wide-world-importers/wwi-azure-functions/wwi-azure-functions.csproj b/samples/databases/wide-world-importers/wwi-azure-functions/wwi-azure-functions.csproj index c5a3f4b794..fe5616f118 100644 --- a/samples/databases/wide-world-importers/wwi-azure-functions/wwi-azure-functions.csproj +++ b/samples/databases/wide-world-importers/wwi-azure-functions/wwi-azure-functions.csproj @@ -5,8 +5,7 @@ wwi_azure_functions - - + diff --git a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Movement.sql b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Movement.sql index 7691ebe3cd..45fb853f85 100644 --- a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Movement.sql +++ b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Movement.sql @@ -16,7 +16,8 @@ CONSTRAINT [FK_Fact_Movement_Stock_Item_Key_Dimension_Stock Item] FOREIGN KEY ([Stock Item Key]) REFERENCES [Dimension].[Stock Item] ([Stock Item Key]), CONSTRAINT [FK_Fact_Movement_Supplier_Key_Dimension_Supplier] FOREIGN KEY ([Supplier Key]) REFERENCES [Dimension].[Supplier] ([Supplier Key]), CONSTRAINT [FK_Fact_Movement_Transaction_Type_Key_Dimension_Transaction Type] FOREIGN KEY ([Transaction Type Key]) REFERENCES [Dimension].[Transaction Type] ([Transaction Type Key]) -); +) +ON [PS_Date] ([Date Key]); GO diff --git a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Order.sql b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Order.sql index a08e6054db..7d2daeec99 100644 --- a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Order.sql +++ b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Order.sql @@ -26,7 +26,8 @@ CONSTRAINT [FK_Fact_Order_Picker_Key_Dimension_Employee] FOREIGN KEY ([Picker Key]) REFERENCES [Dimension].[Employee] ([Employee Key]), CONSTRAINT [FK_Fact_Order_Salesperson_Key_Dimension_Employee] FOREIGN KEY ([Salesperson Key]) REFERENCES [Dimension].[Employee] ([Employee Key]), CONSTRAINT [FK_Fact_Order_Stock_Item_Key_Dimension_Stock Item] FOREIGN KEY ([Stock Item Key]) REFERENCES [Dimension].[Stock Item] ([Stock Item Key]) -); +) +ON [PS_Date] ([Order Date Key]); GO diff --git a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Purchase.sql b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Purchase.sql index 8beaba699b..f5d616ffa0 100644 --- a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Purchase.sql +++ b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Purchase.sql @@ -14,7 +14,8 @@ CONSTRAINT [FK_Fact_Purchase_Date_Key_Dimension_Date] FOREIGN KEY ([Date Key]) REFERENCES [Dimension].[Date] ([Date]), CONSTRAINT [FK_Fact_Purchase_Stock_Item_Key_Dimension_Stock Item] FOREIGN KEY ([Stock Item Key]) REFERENCES [Dimension].[Stock Item] ([Stock Item Key]), CONSTRAINT [FK_Fact_Purchase_Supplier_Key_Dimension_Supplier] FOREIGN KEY ([Supplier Key]) REFERENCES [Dimension].[Supplier] ([Supplier Key]) -); +) +ON [PS_Date] ([Date Key]); GO diff --git a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Sale.sql b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Sale.sql index a6c0795d63..2c2a79b086 100644 --- a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Sale.sql +++ b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Sale.sql @@ -28,7 +28,8 @@ CONSTRAINT [FK_Fact_Sale_Invoice_Date_Key_Dimension_Date] FOREIGN KEY ([Invoice Date Key]) REFERENCES [Dimension].[Date] ([Date]), CONSTRAINT [FK_Fact_Sale_Salesperson_Key_Dimension_Employee] FOREIGN KEY ([Salesperson Key]) REFERENCES [Dimension].[Employee] ([Employee Key]), CONSTRAINT [FK_Fact_Sale_Stock_Item_Key_Dimension_Stock Item] FOREIGN KEY ([Stock Item Key]) REFERENCES [Dimension].[Stock Item] ([Stock Item Key]) -); +) +ON [PS_Date] ([Invoice Date Key]); GO diff --git a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Transaction.sql b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Transaction.sql index 54469f3b57..30d662ce21 100644 --- a/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Transaction.sql +++ b/samples/databases/wide-world-importers/wwi-dw-ssdt/wwi-dw-ssdt/Fact/Tables/Transaction.sql @@ -24,7 +24,8 @@ CONSTRAINT [FK_Fact_Transaction_Payment_Method_Key_Dimension_Payment Method] FOREIGN KEY ([Payment Method Key]) REFERENCES [Dimension].[Payment Method] ([Payment Method Key]), CONSTRAINT [FK_Fact_Transaction_Supplier_Key_Dimension_Supplier] FOREIGN KEY ([Supplier Key]) REFERENCES [Dimension].[Supplier] ([Supplier Key]), CONSTRAINT [FK_Fact_Transaction_Transaction_Type_Key_Dimension_Transaction Type] FOREIGN KEY ([Transaction Type Key]) REFERENCES [Dimension].[Transaction Type] ([Transaction Type Key]) -); +) +ON [PS_Date] ([Date Key]); GO diff --git a/samples/databases/wide-world-importers/wwi-sample.sln b/samples/databases/wide-world-importers/wwi-sample.sln index 38289175c8..3c5b0426ed 100644 --- a/samples/databases/wide-world-importers/wwi-sample.sln +++ b/samples/databases/wide-world-importers/wwi-sample.sln @@ -11,6 +11,10 @@ Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "WideWorldImporters", "wwi-s EndProject Project("{159641D6-6404-4A2A-AE62-294DE0FE8301}") = "Daily ETL", "wwi-ssis\WWI-SSIS\Daily ETL.dtproj", "{925A4107-F621-4697-955C-1EB222C4AB3C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wwi-app", "wwi-app\wwi-app.csproj", "{6906F917-3781-423C-9B29-A7D1DCE7EE0D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wwi-azure-functions", "wwi-azure-functions\wwi-azure-functions.csproj", "{CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +55,18 @@ Global {925A4107-F621-4697-955C-1EB222C4AB3C}.Development|Any CPU.Build.0 = Development {925A4107-F621-4697-955C-1EB222C4AB3C}.Release|Any CPU.ActiveCfg = Development {925A4107-F621-4697-955C-1EB222C4AB3C}.Release|Any CPU.Build.0 = Development + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Development|Any CPU.ActiveCfg = Debug|Any CPU + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Development|Any CPU.Build.0 = Debug|Any CPU + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6906F917-3781-423C-9B29-A7D1DCE7EE0D}.Release|Any CPU.Build.0 = Release|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Development|Any CPU.ActiveCfg = Debug|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Development|Any CPU.Build.0 = Debug|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEFEBC2F-1BF5-452C-89FF-9BD1892D5DA6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/features/shrink/shrink-driver/.gitignore b/samples/features/shrink/shrink-driver/.gitignore new file mode 100644 index 0000000000..e4a1d65eab --- /dev/null +++ b/samples/features/shrink/shrink-driver/.gitignore @@ -0,0 +1,2 @@ +# Run logs produced by Invoke-ShrinkDriver +*.log diff --git a/samples/features/shrink/shrink-driver/CHANGELOG.md b/samples/features/shrink/shrink-driver/CHANGELOG.md new file mode 100644 index 0000000000..8cb0590f1c --- /dev/null +++ b/samples/features/shrink/shrink-driver/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to the ShrinkDriver sample are documented in this file. + +## [1.2.0] - 2026-09-11 + +### Added + +- Safety valve (on by default): the run stops if the size of active transaction log or + the size of the persistent version store (PVS) nears a level where the shrink itself + could cause an out-of-log condition or grow the database. + +## [1.1.0] - 2026-08-27 + +### Changed + +- A shrink blocked by the database low watermark not advancing because of an open + transaction or other reasons (error 49537) now ends shrink for that file with a distinct + "run shrink again later" outcome, instead of retrying it as a generic transient error. + The shrink of other files in the same run continues. + +## [1.0.0] - 2026-07-16 + +### Added + +- `Invoke-ShrinkDriver`: reclaims allocated but unused space from a database's `ROWS` + data files by running `DBCC SHRINKFILE` on multiple sessions in parallel. +- `Report` mode (default) and `Shrink` mode. `Report` needs only a connection; `Shrink` + needs `db_owner` or `sysadmin`. +- Incremental, step-based shrinking toward an optional per-file target size + (`-FileTargetSizeGiB`, `-StepGiB`), skipping files with less than `-MinReclaimGiB` of + reclaimable space. +- `-TruncateOnly` (release tail free space only) and `-NoTruncate` (repack only) modes. +- `WAIT_AT_LOW_PRIORITY` support (`-WaitAtLowPriority`, `-AbortAfterWait`). +- Transient-failure retries with exponential backoff and full jitter, plus a + connection-level retry provider and an outer reconnect loop that rides out an Azure SQL + restart or failover. +- Stuck detection: a shrink blocked or making no progress for `-StuckWindowSeconds` is + cancelled and retried. +- Graceful shutdown: two-stage Ctrl+C and an optional `-MaxRuntimeMinutes`, both of which + reassess in-flight files to real outcomes. +- Per-file outcome buckets and an end-of-run advisory listing files that still have + reclaimable space, so a follow-up run can recover more. +- A periodic status report written to the console and a log file, with a structured + result object available via `-PassThru`. +- Entra ID, Windows, and SQL authentication; validate-first connections with an opt-in + `-TrustServerCertificate` fallback; a secure `SecureString` password prompt. +- Support for SQL Server 2022 or later, Azure SQL Managed Instance, and Azure SQL Database. diff --git a/samples/features/shrink/shrink-driver/README.md b/samples/features/shrink/shrink-driver/README.md new file mode 100644 index 0000000000..982bd25c2b --- /dev/null +++ b/samples/features/shrink/shrink-driver/README.md @@ -0,0 +1,151 @@ +# ShrinkDriver + +Reclaim allocated but unused space from the data files of a MSSQL database +by running parallel `DBCC SHRINKFILE` operations, with progress monitoring, +incremental shrinking, and automatic retries. + +## What it does + +- Runs in two modes: `Report` (default) shows each file's used, allocated, and reclaimable space without changing anything; `Shrink` performs the shrink. +- Shrinks multiple data files at once (one session per file) to reduce total run time. +- Shrinks each file gradually in incremental steps toward an optional target size, instead of in one large operation. +- Retries transient failures with backoff, and moves on from files that cannot shrink further. +- Skips files with little unused space to reclaim. +- Optionally runs shrink at low lock priority to reduce blocking of other queries. +- Writes a status report to the console and a log file at a regular interval, and stops on Ctrl+C or an optional time limit. +- Supports Entra ID, Windows, and SQL authentication when connecting to the database. + +## Requirements + +- SQL Server 2022 or later, Azure SQL Managed Instance, or Azure SQL Database. +- To shrink (`-Mode Shrink`): membership in the `db_owner` database role, or the `sysadmin` server role. +- To report (`-Mode Report`, the default): connection to the database. +- PowerShell 7 or later. +- The `SqlServer` module: + `Install-Module SqlServer -Scope CurrentUser`. + +## Usage + +Load the script, then call `Invoke-ShrinkDriver`: + +```powershell +. .\src\ShrinkDriver.ps1 + +# Report (default): show each file's used, allocated, and reclaimable space, without changing anything +Invoke-ShrinkDriver -ServerName myserver.database.windows.net -DatabaseName MyDb + +# Shrink with Entra ID auth (default) to the smallest possible size, working on up to 5 files concurrently +Invoke-ShrinkDriver -ServerName myserver.database.windows.net -DatabaseName MyDb -Mode Shrink -Sessions 5 + +# Shrink with Windows auth: don't shrink below 500 GiB, working on up to 8 files concurrently +Invoke-ShrinkDriver -ServerName sql01 -DatabaseName MyDb -Mode Shrink -AuthType Windows -FileTargetSizeGiB 500 -Sessions 8 + +# Shrink with SQL auth (prompts securely for the password when it is not supplied) +Invoke-ShrinkDriver -ServerName sql01 -DatabaseName MyDb -Mode Shrink -AuthType SQL -SqlLogin appuser + +# Connect to an instance with a self-signed certificate +Invoke-ShrinkDriver -ServerName devsql01 -DatabaseName MyDb -Mode Shrink -AuthType Windows -TrustServerCertificate +``` + +For the full list of parameters and what they do: + +```powershell +Get-Help Invoke-ShrinkDriver -Full +``` + +## Output + +Progress is written to the console and mirrored to a log file — by default a +timestamped `shrink-