Forráskód Böngészése

提交促销优惠券管理

qmj 2 hete
szülő
commit
fa1baac662
85 módosított fájl, 6308 hozzáadás és 325 törlés
  1. 72 5
      .claude/skills/speckit-analyze/SKILL.md
  2. 70 2
      .claude/skills/speckit-checklist/SKILL.md
  3. 73 5
      .claude/skills/speckit-clarify/SKILL.md
  4. 69 1
      .claude/skills/speckit-constitution/SKILL.md
  5. 53 0
      .claude/skills/speckit-git-commit/SKILL.md
  6. 72 0
      .claude/skills/speckit-git-feature/SKILL.md
  7. 54 0
      .claude/skills/speckit-git-initialize/SKILL.md
  8. 50 0
      .claude/skills/speckit-git-remote/SKILL.md
  9. 54 0
      .claude/skills/speckit-git-validate/SKILL.md
  10. 5 2
      .claude/skills/speckit-implement/SKILL.md
  11. 6 8
      .claude/skills/speckit-plan/SKILL.md
  12. 52 29
      .claude/skills/speckit-specify/SKILL.md
  13. 5 3
      .claude/skills/speckit-tasks/SKILL.md
  14. 69 1
      .claude/skills/speckit-taskstoissues/SKILL.md
  15. 149 0
      .specify/extensions.yml
  16. 23 0
      .specify/extensions/.registry
  17. 100 0
      .specify/extensions/git/README.md
  18. 48 0
      .specify/extensions/git/commands/speckit.git.commit.md
  19. 67 0
      .specify/extensions/git/commands/speckit.git.feature.md
  20. 49 0
      .specify/extensions/git/commands/speckit.git.initialize.md
  21. 45 0
      .specify/extensions/git/commands/speckit.git.remote.md
  22. 49 0
      .specify/extensions/git/commands/speckit.git.validate.md
  23. 62 0
      .specify/extensions/git/config-template.yml
  24. 140 0
      .specify/extensions/git/extension.yml
  25. 62 0
      .specify/extensions/git/git-config.yml
  26. 140 0
      .specify/extensions/git/scripts/bash/auto-commit.sh
  27. 453 0
      .specify/extensions/git/scripts/bash/create-new-feature.sh
  28. 54 0
      .specify/extensions/git/scripts/bash/git-common.sh
  29. 54 0
      .specify/extensions/git/scripts/bash/initialize-repo.sh
  30. 169 0
      .specify/extensions/git/scripts/powershell/auto-commit.ps1
  31. 403 0
      .specify/extensions/git/scripts/powershell/create-new-feature.ps1
  32. 51 0
      .specify/extensions/git/scripts/powershell/git-common.ps1
  33. 69 0
      .specify/extensions/git/scripts/powershell/initialize-repo.ps1
  34. 2 2
      .specify/init-options.json
  35. 12 4
      .specify/integration.json
  36. 11 13
      .specify/integrations/claude.manifest.json
  37. 9 8
      .specify/integrations/speckit.manifest.json
  38. 381 6
      .specify/scripts/powershell/common.ps1
  39. 10 3
      .specify/scripts/powershell/create-new-feature.ps1
  40. 9 5
      .specify/scripts/powershell/setup-plan.ps1
  41. 74 0
      .specify/scripts/powershell/setup-tasks.ps1
  42. 2 2
      .specify/templates/checklist-template.md
  43. 23 14
      .specify/templates/plan-template.md
  44. 8 5
      .specify/templates/spec-template.md
  45. 6 5
      .specify/templates/tasks-template.md
  46. 77 0
      .specify/workflows/speckit/workflow.yml
  47. 13 0
      .specify/workflows/workflow-registry.json
  48. 6 1
      CLAUDE.md
  49. 118 0
      docs/research-meituan-discount-product.md
  50. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java
  51. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java
  52. 0 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPointController.java
  53. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java
  54. 78 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCouponController.java
  55. 192 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderPromotion.java
  56. 3 13
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivity.java
  57. 1 9
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivityRule.java
  58. 10 19
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponBatch.java
  59. 1 9
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponRule.java
  60. 9 11
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionUserCoupon.java
  61. 29 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderPromotionMapper.java
  62. 43 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java
  63. 28 5
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityRuleMapper.java
  64. 43 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponBatchMapper.java
  65. 31 7
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponRuleMapper.java
  66. 28 5
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionUserCouponMapper.java
  67. 17 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java
  68. 21 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java
  69. 28 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponBatchService.java
  70. 27 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionUserCouponService.java
  71. 71 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java
  72. 795 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java
  73. 156 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java
  74. 185 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionUserCouponServiceImpl.java
  75. 42 0
      ruoyi-system/src/main/resources/mapper/system/PosOrderPromotionMapper.xml
  76. 77 12
      ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml
  77. 9 0
      ruoyi-system/src/main/resources/mapper/system/PromotionActivityRuleMapper.xml
  78. 7 0
      ruoyi-system/src/main/resources/mapper/system/PromotionCouponBatchMapper.xml
  79. 11 0
      ruoyi-system/src/main/resources/mapper/system/PromotionCouponRuleMapper.xml
  80. 52 0
      specs/008-promotion-coupon/checklists/calc-logic.md
  81. 62 0
      specs/008-promotion-coupon/checklists/requirements.md
  82. 144 15
      specs/008-promotion-coupon/plan.md
  83. 104 39
      specs/008-promotion-coupon/spec.md
  84. 218 46
      specs/008-promotion-coupon/tasks.md
  85. 25 0
      updatesql/sql.md

+ 72 - 5
.claude/skills/speckit-analyze/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/analyze.md"
   source: "templates/commands/analyze.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -19,15 +19,50 @@ $ARGUMENTS
 
 
 You **MUST** consider the user input before proceeding (if not empty).
 You **MUST** consider the user input before proceeding (if not empty).
 
 
+## Pre-Execution Checks
+
+**Check for extension hooks (before analysis)**:
+- Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.before_analyze` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Pre-Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Pre-Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+
+    Wait for the result of the hook command before proceeding to the Goal.
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Goal
 ## Goal
 
 
-Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
+Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
 
 
 ## Operating Constraints
 ## Operating Constraints
 
 
 **STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
 **STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
 
 
-**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
+**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
 
 
 ## Execution Steps
 ## Execution Steps
 
 
@@ -163,14 +198,46 @@ Output a Markdown report (no file writes) with the following structure:
 
 
 At end of report, output a concise Next Actions block:
 At end of report, output a concise Next Actions block:
 
 
-- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
+- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
 - If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
 - If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
-- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
+- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
 
 
 ### 8. Offer Remediation
 ### 8. Offer Remediation
 
 
 Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
 Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
 
 
+### 9. Check for extension hooks
+
+After reporting, check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.after_analyze` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Operating Principles
 ## Operating Principles
 
 
 ### Context Efficiency
 ### Context Efficiency

+ 70 - 2
.claude/skills/speckit-checklist/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/checklist.md"
   source: "templates/commands/checklist.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -40,6 +40,41 @@ $ARGUMENTS
 
 
 You **MUST** consider the user input before proceeding (if not empty).
 You **MUST** consider the user input before proceeding (if not empty).
 
 
+## Pre-Execution Checks
+
+**Check for extension hooks (before checklist generation)**:
+- Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.before_checklist` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Pre-Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Pre-Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+
+    Wait for the result of the hook command before proceeding to the Execution Steps.
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Execution Steps
 ## Execution Steps
 
 
 1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
 1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
@@ -221,7 +256,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - Actor/timing
    - Actor/timing
    - Any explicit user-specified must-have items incorporated
    - Any explicit user-specified must-have items incorporated
 
 
-**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
+**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
 
 
 - Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
 - Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
 - Simple, memorable filenames that indicate checklist purpose
 - Simple, memorable filenames that indicate checklist purpose
@@ -302,3 +337,36 @@ Sample items:
 - Correct: Validation of requirement quality
 - Correct: Validation of requirement quality
 - Wrong: "Does it do X?"
 - Wrong: "Does it do X?"
 - Correct: "Is X clearly specified?"
 - Correct: "Is X clearly specified?"
+
+## Post-Execution Checks
+
+**Check for extension hooks (after checklist generation)**:
+Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.after_checklist` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

+ 73 - 5
.claude/skills/speckit-clarify/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/clarify.md"
   source: "templates/commands/clarify.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -19,11 +19,46 @@ $ARGUMENTS
 
 
 You **MUST** consider the user input before proceeding (if not empty).
 You **MUST** consider the user input before proceeding (if not empty).
 
 
+## Pre-Execution Checks
+
+**Check for extension hooks (before clarification)**:
+- Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.before_clarify` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Pre-Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Pre-Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+
+    Wait for the result of the hook command before proceeding to the Outline.
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Outline
 ## Outline
 
 
 Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
 Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
 
 
-Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
+Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
 
 
 Execution steps:
 Execution steps:
 
 
@@ -31,7 +66,7 @@ Execution steps:
    - `FEATURE_DIR`
    - `FEATURE_DIR`
    - `FEATURE_SPEC`
    - `FEATURE_SPEC`
    - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
    - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
-   - If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
+   - If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
    - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
    - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
 
 
 2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
 2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
@@ -170,13 +205,13 @@ Execution steps:
    - Path to updated spec.
    - Path to updated spec.
    - Sections touched (list names).
    - Sections touched (list names).
    - Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
    - Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
-   - If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
+   - If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
    - Suggested next command.
    - Suggested next command.
 
 
 Behavior rules:
 Behavior rules:
 
 
 - If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
 - If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
-- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
+- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
 - Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
 - Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
 - Avoid speculative tech stack questions unless the absence blocks functional clarity.
 - Avoid speculative tech stack questions unless the absence blocks functional clarity.
 - Respect user early termination signals ("stop", "done", "proceed").
 - Respect user early termination signals ("stop", "done", "proceed").
@@ -184,3 +219,36 @@ Behavior rules:
 - If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
 - If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
 
 
 Context for prioritization: $ARGUMENTS
 Context for prioritization: $ARGUMENTS
+
+## Post-Execution Checks
+
+**Check for extension hooks (after clarification)**:
+Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.after_clarify` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

+ 69 - 1
.claude/skills/speckit-constitution/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/constitution.md"
   source: "templates/commands/constitution.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -19,6 +19,41 @@ $ARGUMENTS
 
 
 You **MUST** consider the user input before proceeding (if not empty).
 You **MUST** consider the user input before proceeding (if not empty).
 
 
+## Pre-Execution Checks
+
+**Check for extension hooks (before constitution update)**:
+- Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.before_constitution` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Pre-Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Pre-Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+
+    Wait for the result of the hook command before proceeding to the Outline.
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Outline
 ## Outline
 
 
 You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
 You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
@@ -87,3 +122,36 @@ If the user supplies partial updates (e.g., only one principle revision), still
 If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
 If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
 
 
 Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
 Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
+
+## Post-Execution Checks
+
+**Check for extension hooks (after constitution update)**:
+Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.after_constitution` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

+ 53 - 0
.claude/skills/speckit-git-commit/SKILL.md

@@ -0,0 +1,53 @@
+---
+name: speckit-git-commit
+description: Auto-commit changes after a Spec Kit command completes
+compatibility: Requires spec-kit project structure with .specify/ directory
+metadata:
+  author: github-spec-kit
+  source: git:commands/speckit.git.commit.md
+---
+
+# Auto-Commit Changes
+
+Automatically stage and commit all changes after a Spec Kit command completes.
+
+## Behavior
+
+This command is invoked as a hook after (or before) core commands. It:
+
+1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
+2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
+3. Looks up the specific event key to see if auto-commit is enabled
+4. Falls back to `auto_commit.default` if no event-specific key exists
+5. Uses the per-command `message` if configured, otherwise a default message
+6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
+
+## Execution
+
+Determine the event name from the hook that triggered this command, then run the script:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
+
+Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
+
+## Configuration
+
+In `.specify/extensions/git/git-config.yml`:
+
+```yaml
+auto_commit:
+  default: false          # Global toggle — set true to enable for all commands
+  after_specify:
+    enabled: true          # Override per-command
+    message: "[Spec Kit] Add specification"
+  after_plan:
+    enabled: false
+    message: "[Spec Kit] Add implementation plan"
+```
+
+## Graceful Degradation
+
+- If Git is not available or the current directory is not a repository: skips with a warning
+- If no config file exists: skips (disabled by default)
+- If no changes to commit: skips with a message

+ 72 - 0
.claude/skills/speckit-git-feature/SKILL.md

@@ -0,0 +1,72 @@
+---
+name: speckit-git-feature
+description: Create a feature branch with sequential or timestamp numbering
+compatibility: Requires spec-kit project structure with .specify/ directory
+metadata:
+  author: github-spec-kit
+  source: git:commands/speckit.git.feature.md
+---
+
+# Create Feature Branch
+
+Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit-specify` workflow.
+
+## User Input
+
+```text
+$ARGUMENTS
+```
+
+You **MUST** consider the user input before proceeding (if not empty).
+
+## Environment Variable Override
+
+If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
+- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
+- `--short-name`, `--number`, and `--timestamp` flags are ignored
+- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
+
+## Prerequisites
+
+- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, warn the user and skip branch creation
+
+## Branch Numbering Mode
+
+Determine the branch numbering strategy by checking configuration in this order:
+
+1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
+2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
+3. Default to `sequential` if neither exists
+
+## Execution
+
+Generate a concise short name (2-4 words) for the branch:
+- Analyze the feature description and extract the most meaningful keywords
+- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
+- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
+
+Run the appropriate script based on your platform:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
+- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
+- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
+
+**IMPORTANT**:
+- Do NOT pass `--number` — the script determines the correct next number automatically
+- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
+- You must only ever run this script once per feature
+- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
+
+## Graceful Degradation
+
+If Git is not installed or the current directory is not a Git repository:
+- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
+- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
+
+## Output
+
+The script outputs JSON with:
+- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
+- `FEATURE_NUM`: The numeric or timestamp prefix used

+ 54 - 0
.claude/skills/speckit-git-initialize/SKILL.md

@@ -0,0 +1,54 @@
+---
+name: speckit-git-initialize
+description: Initialize a Git repository with an initial commit
+compatibility: Requires spec-kit project structure with .specify/ directory
+metadata:
+  author: github-spec-kit
+  source: git:commands/speckit.git.initialize.md
+---
+
+# Initialize Git Repository
+
+Initialize a Git repository in the current project directory if one does not already exist.
+
+## Execution
+
+Run the appropriate script from the project root:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
+
+If the extension scripts are not found, fall back to:
+- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
+- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
+
+The script handles all checks internally:
+- Skips if Git is not available
+- Skips if already inside a Git repository
+- Runs `git init`, `git add .`, and `git commit` with an initial commit message
+
+## Customization
+
+Replace the script to add project-specific Git initialization steps:
+- Custom `.gitignore` templates
+- Default branch naming (`git config init.defaultBranch`)
+- Git LFS setup
+- Git hooks installation
+- Commit signing configuration
+- Git Flow initialization
+
+## Output
+
+On success:
+- `✓ Git repository initialized`
+
+## Graceful Degradation
+
+If Git is not installed:
+- Warn the user
+- Skip repository initialization
+- The project continues to function without Git (specs can still be created under `specs/`)
+
+If Git is installed but `git init`, `git add .`, or `git commit` fails:
+- Surface the error to the user
+- Stop this command rather than continuing with a partially initialized repository

+ 50 - 0
.claude/skills/speckit-git-remote/SKILL.md

@@ -0,0 +1,50 @@
+---
+name: speckit-git-remote
+description: Detect Git remote URL for GitHub integration
+compatibility: Requires spec-kit project structure with .specify/ directory
+metadata:
+  author: github-spec-kit
+  source: git:commands/speckit.git.remote.md
+---
+
+# Detect Git Remote URL
+
+Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
+
+## Prerequisites
+
+- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, output a warning and return empty:
+  ```
+  [specify] Warning: Git repository not detected; cannot determine remote URL
+  ```
+
+## Execution
+
+Run the following command to get the remote URL:
+
+```bash
+git config --get remote.origin.url
+```
+
+## Output
+
+Parse the remote URL and determine:
+
+1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
+2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
+3. **Is GitHub**: Whether the remote points to a GitHub repository
+
+Supported URL formats:
+- HTTPS: `https://github.com/<owner>/<repo>.git`
+- SSH: `git@github.com:<owner>/<repo>.git`
+
+> [!CAUTION]
+> ONLY report a GitHub repository if the remote URL actually points to github.com.
+> Do NOT assume the remote is GitHub if the URL format doesn't match.
+
+## Graceful Degradation
+
+If Git is not installed, the directory is not a Git repository, or no remote is configured:
+- Return an empty result
+- Do NOT error — other workflows should continue without Git remote information

+ 54 - 0
.claude/skills/speckit-git-validate/SKILL.md

@@ -0,0 +1,54 @@
+---
+name: speckit-git-validate
+description: Validate current branch follows feature branch naming conventions
+compatibility: Requires spec-kit project structure with .specify/ directory
+metadata:
+  author: github-spec-kit
+  source: git:commands/speckit.git.validate.md
+---
+
+# Validate Feature Branch
+
+Validate that the current Git branch follows the expected feature branch naming conventions.
+
+## Prerequisites
+
+- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, output a warning and skip validation:
+  ```
+  [specify] Warning: Git repository not detected; skipped branch validation
+  ```
+
+## Validation Rules
+
+Get the current branch name:
+
+```bash
+git rev-parse --abbrev-ref HEAD
+```
+
+The branch name must match one of these patterns:
+
+1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
+2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
+
+## Execution
+
+If on a feature branch (matches either pattern):
+- Output: `✓ On feature branch: <branch-name>`
+- Check if the corresponding spec directory exists under `specs/`:
+  - For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
+  - For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
+- If spec directory exists: `✓ Spec directory found: <path>`
+- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
+
+If NOT on a feature branch:
+- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
+- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
+
+## Graceful Degradation
+
+If Git is not installed or the directory is not a Git repository:
+- Check the `SPECIFY_FEATURE` environment variable as a fallback
+- If set, validate that value against the naming patterns
+- If not set, skip validation with a warning

+ 5 - 2
.claude/skills/speckit-implement/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/implement.md"
   source: "templates/commands/implement.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -29,6 +29,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
 - For each executable hook, output the following based on its `optional` flag:
 - For each executable hook, output the following based on its `optional` flag:
   - **Optional hook** (`optional: true`):
   - **Optional hook** (`optional: true`):
     ```
     ```
@@ -94,6 +95,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - **IF EXISTS**: Read data-model.md for entities and relationships
    - **IF EXISTS**: Read data-model.md for entities and relationships
    - **IF EXISTS**: Read contracts/ for API specifications and test requirements
    - **IF EXISTS**: Read contracts/ for API specifications and test requirements
    - **IF EXISTS**: Read research.md for technical decisions and constraints
    - **IF EXISTS**: Read research.md for technical decisions and constraints
+   - **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
    - **IF EXISTS**: Read quickstart.md for integration scenarios
    - **IF EXISTS**: Read quickstart.md for integration scenarios
 
 
 4. **Project Setup Verification**:
 4. **Project Setup Verification**:
@@ -175,7 +177,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - Confirm the implementation follows the technical plan
    - Confirm the implementation follows the technical plan
    - Report final status with summary of completed work
    - Report final status with summary of completed work
 
 
-Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
+Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
 
 
 10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
 10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
     - If it exists, read it and look for entries under the `hooks.after_implement` key
     - If it exists, read it and look for entries under the `hooks.after_implement` key
@@ -184,6 +186,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task
     - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
     - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
       - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
       - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
       - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
       - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+    - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
     - For each executable hook, output the following based on its `optional` flag:
     - For each executable hook, output the following based on its `optional` flag:
       - **Optional hook** (`optional: true`):
       - **Optional hook** (`optional: true`):
         ```
         ```

+ 6 - 8
.claude/skills/speckit-plan/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/plan.md"
   source: "templates/commands/plan.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -29,6 +29,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
 - For each executable hook, output the following based on its `optional` flag:
 - For each executable hook, output the following based on its `optional` flag:
   - **Optional hook** (`optional: true`):
   - **Optional hook** (`optional: true`):
     ```
     ```
@@ -77,6 +78,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+   - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
    - For each executable hook, output the following based on its `optional` flag:
    - For each executable hook, output the following based on its `optional` flag:
      - **Optional hook** (`optional: true`):
      - **Optional hook** (`optional: true`):
        ```
        ```
@@ -140,15 +142,11 @@ You **MUST** consider the user input before proceeding (if not empty).
    - Skip if project is purely internal (build scripts, one-off tools, etc.)
    - Skip if project is purely internal (build scripts, one-off tools, etc.)
 
 
 3. **Agent context update**:
 3. **Agent context update**:
-   - Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType claude`
-   - These scripts detect which AI agent is in use
-   - Update the appropriate agent-specific context file
-   - Add only new technology from current plan
-   - Preserve manual additions between markers
+   - Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `CLAUDE.md` to point to the plan file created in step 1 (the IMPL_PLAN path)
 
 
-**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
+**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
 
 
 ## Key rules
 ## Key rules
 
 
-- Use absolute paths
+- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
 - ERROR on gate failures or unresolved clarifications
 - ERROR on gate failures or unresolved clarifications

+ 52 - 29
.claude/skills/speckit-specify/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/specify.md"
   source: "templates/commands/specify.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -29,6 +29,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
 - For each executable hook, output the following based on its `optional` flag:
 - For each executable hook, output the following based on its `optional` flag:
   - **Optional hook** (`optional: true`):
   - **Optional hook** (`optional: true`):
     ```
     ```
@@ -55,11 +56,11 @@ You **MUST** consider the user input before proceeding (if not empty).
 
 
 ## Outline
 ## Outline
 
 
-The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
+The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
 
 
 Given that feature description, do this:
 Given that feature description, do this:
 
 
-1. **Generate a concise short name** (2-4 words) for the branch:
+1. **Generate a concise short name** (2-4 words) for the feature:
    - Analyze the feature description and extract the most meaningful keywords
    - Analyze the feature description and extract the most meaningful keywords
    - Create a 2-4 word short name that captures the essence of the feature
    - Create a 2-4 word short name that captures the essence of the feature
    - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
    - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
@@ -71,30 +72,47 @@ Given that feature description, do this:
      - "Create a dashboard for analytics" → "analytics-dashboard"
      - "Create a dashboard for analytics" → "analytics-dashboard"
      - "Fix payment processing timeout bug" → "fix-payment-timeout"
      - "Fix payment processing timeout bug" → "fix-payment-timeout"
 
 
-2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
+2. **Branch creation** (optional, via hook):
 
 
-   **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
-   - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
-   - If `"sequential"` or absent, do not add any extra flag (default behavior)
+   If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
 
 
-   - Bash example: `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" --json --short-name "user-auth" "Add user authentication"`
-   - Bash (timestamp): `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" --json --timestamp --short-name "user-auth" "Add user authentication"`
-   - PowerShell example: `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" -Json -ShortName "user-auth" "Add user authentication"`
-   - PowerShell (timestamp): `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
+   If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
 
 
-   **IMPORTANT**:
-   - Do NOT pass `--number` — the script determines the correct next number automatically
-   - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
-   - You must only ever run this script once per feature
-   - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
-   - The JSON output will contain BRANCH_NAME and SPEC_FILE paths
-   - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
+3. **Create the spec feature directory**:
+
+   Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
+
+   **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
+   1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
+   2. Otherwise, auto-generate it under `specs/`:
+      - Check `.specify/init-options.json` for `branch_numbering`
+      - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
+      - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
+      - Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
+      - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
 
 
-3. Load `.specify/templates/spec-template.md` to understand required sections.
+   **Create the directory and spec file**:
+   - `mkdir -p SPECIFY_FEATURE_DIRECTORY`
+   - Copy `.specify/templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
+   - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
+   - Persist the resolved path to `.specify/feature.json`:
+     ```json
+     {
+       "feature_directory": "<resolved feature dir>"
+     }
+     ```
+     Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
+     This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
+
+   **IMPORTANT**:
+   - You must only create one feature per `/speckit-specify` invocation
+   - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
+   - The spec directory and file are always created by this command, never by the hook
 
 
-4. Follow this execution flow:
+4. Load `.specify/templates/spec-template.md` to understand required sections.
 
 
-    1. Parse user description from Input
+5. Follow this execution flow:
+    1. Parse user description from arguments
        If empty: ERROR "No feature description provided"
        If empty: ERROR "No feature description provided"
     2. Extract key concepts from description
     2. Extract key concepts from description
        Identify: actors, actions, data, constraints
        Identify: actors, actions, data, constraints
@@ -118,11 +136,11 @@ Given that feature description, do this:
     7. Identify Key Entities (if data involved)
     7. Identify Key Entities (if data involved)
     8. Return: SUCCESS (spec ready for planning)
     8. Return: SUCCESS (spec ready for planning)
 
 
-5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
+6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
 
 
-6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
+7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
 
 
-   a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
+   a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
 
 
       ```markdown
       ```markdown
       # Specification Quality Checklist: [FEATURE NAME]
       # Specification Quality Checklist: [FEATURE NAME]
@@ -158,7 +176,7 @@ Given that feature description, do this:
       
       
       ## Notes
       ## Notes
       
       
-      - Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
+      - Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
       ```
       ```
 
 
    b. **Run Validation Check**: Review the spec against each checklist item:
    b. **Run Validation Check**: Review the spec against each checklist item:
@@ -167,7 +185,7 @@ Given that feature description, do this:
 
 
    c. **Handle Validation Results**:
    c. **Handle Validation Results**:
 
 
-      - **If all items pass**: Mark checklist complete and proceed to step 7
+      - **If all items pass**: Mark checklist complete and proceed to step 8
 
 
       - **If items fail (excluding [NEEDS CLARIFICATION])**:
       - **If items fail (excluding [NEEDS CLARIFICATION])**:
         1. List the failing items and specific issues
         1. List the failing items and specific issues
@@ -212,15 +230,20 @@ Given that feature description, do this:
 
 
    d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
    d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
 
 
-7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
+8. **Report completion** to the user with:
+   - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
+   - `SPEC_FILE` — the spec file path
+   - Checklist results summary
+   - Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
 
 
-8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
+9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
    - If it exists, read it and look for entries under the `hooks.after_specify` key
    - If it exists, read it and look for entries under the `hooks.after_specify` key
    - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
    - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
    - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
    - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+   - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
    - For each executable hook, output the following based on its `optional` flag:
    - For each executable hook, output the following based on its `optional` flag:
      - **Optional hook** (`optional: true`):
      - **Optional hook** (`optional: true`):
        ```
        ```
@@ -243,7 +266,7 @@ Given that feature description, do this:
        ```
        ```
    - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
    - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
 
 
-**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
+**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
 
 
 ## Quick Guidelines
 ## Quick Guidelines
 
 

+ 5 - 3
.claude/skills/speckit-tasks/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/tasks.md"
   source: "templates/commands/tasks.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -29,6 +29,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
 - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
   - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
 - For each executable hook, output the following based on its `optional` flag:
 - For each executable hook, output the following based on its `optional` flag:
   - **Optional hook** (`optional: true`):
   - **Optional hook** (`optional: true`):
     ```
     ```
@@ -55,7 +56,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 
 
 ## Outline
 ## Outline
 
 
-1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
+1. **Setup**: Run `.specify/scripts/powershell/setup-tasks.ps1 -Json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
 
 
 2. **Load design documents**: Read from FEATURE_DIR:
 2. **Load design documents**: Read from FEATURE_DIR:
    - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
    - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
@@ -73,7 +74,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - Create parallel execution examples per user story
    - Create parallel execution examples per user story
    - Validate task completeness (each user story has all needed tasks, independently testable)
    - Validate task completeness (each user story has all needed tasks, independently testable)
 
 
-4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
+4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
    - Correct feature name from plan.md
    - Correct feature name from plan.md
    - Phase 1: Setup tasks (project initialization)
    - Phase 1: Setup tasks (project initialization)
    - Phase 2: Foundational tasks (blocking prerequisites for all user stories)
    - Phase 2: Foundational tasks (blocking prerequisites for all user stories)
@@ -101,6 +102,7 @@ You **MUST** consider the user input before proceeding (if not empty).
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+   - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
    - For each executable hook, output the following based on its `optional` flag:
    - For each executable hook, output the following based on its `optional` flag:
      - **Optional hook** (`optional: true`):
      - **Optional hook** (`optional: true`):
        ```
        ```

+ 69 - 1
.claude/skills/speckit-taskstoissues/SKILL.md

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   author: "github-spec-kit"
   source: "templates/commands/taskstoissues.md"
   source: "templates/commands/taskstoissues.md"
 user-invocable: true
 user-invocable: true
-disable-model-invocation: true
+disable-model-invocation: false
 ---
 ---
 
 
 
 
@@ -19,6 +19,41 @@ $ARGUMENTS
 
 
 You **MUST** consider the user input before proceeding (if not empty).
 You **MUST** consider the user input before proceeding (if not empty).
 
 
+## Pre-Execution Checks
+
+**Check for extension hooks (before tasks-to-issues conversion)**:
+- Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Pre-Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Pre-Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+
+    Wait for the result of the hook command before proceeding to the Outline.
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+
 ## Outline
 ## Outline
 
 
 1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
 1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
@@ -36,3 +71,36 @@ git config --get remote.origin.url
 
 
 > [!CAUTION]
 > [!CAUTION]
 > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
 > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
+
+## Post-Execution Checks
+
+**Check for extension hooks (after tasks-to-issues conversion)**:
+Check if `.specify/extensions.yml` exists in the project root.
+- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
+- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
+- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
+- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
+  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
+  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
+- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
+- For each executable hook, output the following based on its `optional` flag:
+  - **Optional hook** (`optional: true`):
+    ```
+    ## Extension Hooks
+
+    **Optional Hook**: {extension}
+    Command: `/{command}`
+    Description: {description}
+
+    Prompt: {prompt}
+    To execute: `/{command}`
+    ```
+  - **Mandatory hook** (`optional: false`):
+    ```
+    ## Extension Hooks
+
+    **Automatic Hook**: {extension}
+    Executing: `/{command}`
+    EXECUTE_COMMAND: {command}
+    ```
+- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

+ 149 - 0
.specify/extensions.yml

@@ -0,0 +1,149 @@
+installed:
+- git
+settings:
+  auto_execute_hooks: true
+hooks:
+  before_constitution:
+  - extension: git
+    command: speckit.git.initialize
+    enabled: true
+    optional: false
+    prompt: Execute speckit.git.initialize?
+    description: Initialize Git repository before constitution setup
+    condition: null
+  before_specify:
+  - extension: git
+    command: speckit.git.feature
+    enabled: true
+    optional: false
+    prompt: Execute speckit.git.feature?
+    description: Create feature branch before specification
+    condition: null
+  before_clarify:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before clarification?
+    description: Auto-commit before spec clarification
+    condition: null
+  before_plan:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before planning?
+    description: Auto-commit before implementation planning
+    condition: null
+  before_tasks:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before task generation?
+    description: Auto-commit before task generation
+    condition: null
+  before_implement:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before implementation?
+    description: Auto-commit before implementation
+    condition: null
+  before_checklist:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before checklist?
+    description: Auto-commit before checklist generation
+    condition: null
+  before_analyze:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before analysis?
+    description: Auto-commit before analysis
+    condition: null
+  before_taskstoissues:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit outstanding changes before issue sync?
+    description: Auto-commit before tasks-to-issues conversion
+    condition: null
+  after_constitution:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit constitution changes?
+    description: Auto-commit after constitution update
+    condition: null
+  after_specify:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit specification changes?
+    description: Auto-commit after specification
+    condition: null
+  after_clarify:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit clarification changes?
+    description: Auto-commit after spec clarification
+    condition: null
+  after_plan:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit plan changes?
+    description: Auto-commit after implementation planning
+    condition: null
+  after_tasks:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit task changes?
+    description: Auto-commit after task generation
+    condition: null
+  after_implement:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit implementation changes?
+    description: Auto-commit after implementation
+    condition: null
+  after_checklist:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit checklist changes?
+    description: Auto-commit after checklist generation
+    condition: null
+  after_analyze:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit analysis results?
+    description: Auto-commit after analysis
+    condition: null
+  after_taskstoissues:
+  - extension: git
+    command: speckit.git.commit
+    enabled: true
+    optional: true
+    prompt: Commit after syncing issues?
+    description: Auto-commit after tasks-to-issues conversion
+    condition: null

+ 23 - 0
.specify/extensions/.registry

@@ -0,0 +1,23 @@
+{
+  "schema_version": "1.0",
+  "extensions": {
+    "git": {
+      "version": "1.0.0",
+      "source": "local",
+      "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
+      "enabled": true,
+      "priority": 10,
+      "registered_commands": {
+        "claude": [
+          "speckit.git.feature",
+          "speckit.git.validate",
+          "speckit.git.remote",
+          "speckit.git.initialize",
+          "speckit.git.commit"
+        ]
+      },
+      "registered_skills": [],
+      "installed_at": "2026-06-01T02:01:41.072242+00:00"
+    }
+  }
+}

+ 100 - 0
.specify/extensions/git/README.md

@@ -0,0 +1,100 @@
+# Git Branching Workflow Extension
+
+Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
+
+## Overview
+
+This extension provides Git operations as an optional, self-contained module. It manages:
+
+- **Repository initialization** with configurable commit messages
+- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
+- **Branch validation** to ensure branches follow naming conventions
+- **Git remote detection** for GitHub integration (e.g., issue creation)
+- **Auto-commit** after core commands (configurable per-command with custom messages)
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
+| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
+| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
+| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
+| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
+
+## Hooks
+
+| Event | Command | Optional | Description |
+|-------|---------|----------|-------------|
+| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
+| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
+| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
+| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
+| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
+| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
+| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
+| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
+| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
+| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
+| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
+| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
+| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
+| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
+| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
+| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
+| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
+| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
+
+## Configuration
+
+Configuration is stored in `.specify/extensions/git/git-config.yml`:
+
+```yaml
+# Branch numbering strategy: "sequential" or "timestamp"
+branch_numbering: sequential
+
+# Custom commit message for git init
+init_commit_message: "[Spec Kit] Initial commit"
+
+# Auto-commit per command (all disabled by default)
+# Example: enable auto-commit after specify
+auto_commit:
+  default: false
+  after_specify:
+    enabled: true
+    message: "[Spec Kit] Add specification"
+```
+
+## Installation
+
+```bash
+# Install the bundled git extension (no network required)
+specify extension add git
+```
+
+## Disabling
+
+```bash
+# Disable the git extension (spec creation continues without branching)
+specify extension disable git
+
+# Re-enable it
+specify extension enable git
+```
+
+## Graceful Degradation
+
+When Git is not installed or the directory is not a Git repository:
+- Spec directories are still created under `specs/`
+- Branch creation is skipped with a warning
+- Branch validation is skipped with a warning
+- Remote detection returns empty results
+
+## Scripts
+
+The extension bundles cross-platform scripts:
+
+- `scripts/bash/create-new-feature.sh` — Bash implementation
+- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
+- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
+- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

+ 48 - 0
.specify/extensions/git/commands/speckit.git.commit.md

@@ -0,0 +1,48 @@
+---
+description: "Auto-commit changes after a Spec Kit command completes"
+---
+
+# Auto-Commit Changes
+
+Automatically stage and commit all changes after a Spec Kit command completes.
+
+## Behavior
+
+This command is invoked as a hook after (or before) core commands. It:
+
+1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
+2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
+3. Looks up the specific event key to see if auto-commit is enabled
+4. Falls back to `auto_commit.default` if no event-specific key exists
+5. Uses the per-command `message` if configured, otherwise a default message
+6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
+
+## Execution
+
+Determine the event name from the hook that triggered this command, then run the script:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
+
+Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
+
+## Configuration
+
+In `.specify/extensions/git/git-config.yml`:
+
+```yaml
+auto_commit:
+  default: false          # Global toggle — set true to enable for all commands
+  after_specify:
+    enabled: true          # Override per-command
+    message: "[Spec Kit] Add specification"
+  after_plan:
+    enabled: false
+    message: "[Spec Kit] Add implementation plan"
+```
+
+## Graceful Degradation
+
+- If Git is not available or the current directory is not a repository: skips with a warning
+- If no config file exists: skips (disabled by default)
+- If no changes to commit: skips with a message

+ 67 - 0
.specify/extensions/git/commands/speckit.git.feature.md

@@ -0,0 +1,67 @@
+---
+description: "Create a feature branch with sequential or timestamp numbering"
+---
+
+# Create Feature Branch
+
+Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
+
+## User Input
+
+```text
+$ARGUMENTS
+```
+
+You **MUST** consider the user input before proceeding (if not empty).
+
+## Environment Variable Override
+
+If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
+- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
+- `--short-name`, `--number`, and `--timestamp` flags are ignored
+- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
+
+## Prerequisites
+
+- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, warn the user and skip branch creation
+
+## Branch Numbering Mode
+
+Determine the branch numbering strategy by checking configuration in this order:
+
+1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
+2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
+3. Default to `sequential` if neither exists
+
+## Execution
+
+Generate a concise short name (2-4 words) for the branch:
+- Analyze the feature description and extract the most meaningful keywords
+- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
+- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
+
+Run the appropriate script based on your platform:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
+- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
+- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
+
+**IMPORTANT**:
+- Do NOT pass `--number` — the script determines the correct next number automatically
+- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
+- You must only ever run this script once per feature
+- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
+
+## Graceful Degradation
+
+If Git is not installed or the current directory is not a Git repository:
+- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
+- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
+
+## Output
+
+The script outputs JSON with:
+- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
+- `FEATURE_NUM`: The numeric or timestamp prefix used

+ 49 - 0
.specify/extensions/git/commands/speckit.git.initialize.md

@@ -0,0 +1,49 @@
+---
+description: "Initialize a Git repository with an initial commit"
+---
+
+# Initialize Git Repository
+
+Initialize a Git repository in the current project directory if one does not already exist.
+
+## Execution
+
+Run the appropriate script from the project root:
+
+- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
+
+If the extension scripts are not found, fall back to:
+- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
+- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
+
+The script handles all checks internally:
+- Skips if Git is not available
+- Skips if already inside a Git repository
+- Runs `git init`, `git add .`, and `git commit` with an initial commit message
+
+## Customization
+
+Replace the script to add project-specific Git initialization steps:
+- Custom `.gitignore` templates
+- Default branch naming (`git config init.defaultBranch`)
+- Git LFS setup
+- Git hooks installation
+- Commit signing configuration
+- Git Flow initialization
+
+## Output
+
+On success:
+- `✓ Git repository initialized`
+
+## Graceful Degradation
+
+If Git is not installed:
+- Warn the user
+- Skip repository initialization
+- The project continues to function without Git (specs can still be created under `specs/`)
+
+If Git is installed but `git init`, `git add .`, or `git commit` fails:
+- Surface the error to the user
+- Stop this command rather than continuing with a partially initialized repository

+ 45 - 0
.specify/extensions/git/commands/speckit.git.remote.md

@@ -0,0 +1,45 @@
+---
+description: "Detect Git remote URL for GitHub integration"
+---
+
+# Detect Git Remote URL
+
+Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
+
+## Prerequisites
+
+- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, output a warning and return empty:
+  ```
+  [specify] Warning: Git repository not detected; cannot determine remote URL
+  ```
+
+## Execution
+
+Run the following command to get the remote URL:
+
+```bash
+git config --get remote.origin.url
+```
+
+## Output
+
+Parse the remote URL and determine:
+
+1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
+2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
+3. **Is GitHub**: Whether the remote points to a GitHub repository
+
+Supported URL formats:
+- HTTPS: `https://github.com/<owner>/<repo>.git`
+- SSH: `git@github.com:<owner>/<repo>.git`
+
+> [!CAUTION]
+> ONLY report a GitHub repository if the remote URL actually points to github.com.
+> Do NOT assume the remote is GitHub if the URL format doesn't match.
+
+## Graceful Degradation
+
+If Git is not installed, the directory is not a Git repository, or no remote is configured:
+- Return an empty result
+- Do NOT error — other workflows should continue without Git remote information

+ 49 - 0
.specify/extensions/git/commands/speckit.git.validate.md

@@ -0,0 +1,49 @@
+---
+description: "Validate current branch follows feature branch naming conventions"
+---
+
+# Validate Feature Branch
+
+Validate that the current Git branch follows the expected feature branch naming conventions.
+
+## Prerequisites
+
+- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
+- If Git is not available, output a warning and skip validation:
+  ```
+  [specify] Warning: Git repository not detected; skipped branch validation
+  ```
+
+## Validation Rules
+
+Get the current branch name:
+
+```bash
+git rev-parse --abbrev-ref HEAD
+```
+
+The branch name must match one of these patterns:
+
+1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
+2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
+
+## Execution
+
+If on a feature branch (matches either pattern):
+- Output: `✓ On feature branch: <branch-name>`
+- Check if the corresponding spec directory exists under `specs/`:
+  - For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
+  - For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
+- If spec directory exists: `✓ Spec directory found: <path>`
+- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
+
+If NOT on a feature branch:
+- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
+- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
+
+## Graceful Degradation
+
+If Git is not installed or the directory is not a Git repository:
+- Check the `SPECIFY_FEATURE` environment variable as a fallback
+- If set, validate that value against the naming patterns
+- If not set, skip validation with a warning

+ 62 - 0
.specify/extensions/git/config-template.yml

@@ -0,0 +1,62 @@
+# Git Branching Workflow Extension Configuration
+# Copied to .specify/extensions/git/git-config.yml on install
+
+# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
+branch_numbering: sequential
+
+# Commit message used by `git commit` during repository initialization
+init_commit_message: "[Spec Kit] Initial commit"
+
+# Auto-commit before/after core commands.
+# Set "default" to enable for all commands, then override per-command.
+# Each key can be true/false. Message is customizable per-command.
+auto_commit:
+  default: false
+  before_clarify:
+    enabled: false
+    message: "[Spec Kit] Save progress before clarification"
+  before_plan:
+    enabled: false
+    message: "[Spec Kit] Save progress before planning"
+  before_tasks:
+    enabled: false
+    message: "[Spec Kit] Save progress before task generation"
+  before_implement:
+    enabled: false
+    message: "[Spec Kit] Save progress before implementation"
+  before_checklist:
+    enabled: false
+    message: "[Spec Kit] Save progress before checklist"
+  before_analyze:
+    enabled: false
+    message: "[Spec Kit] Save progress before analysis"
+  before_taskstoissues:
+    enabled: false
+    message: "[Spec Kit] Save progress before issue sync"
+  after_constitution:
+    enabled: false
+    message: "[Spec Kit] Add project constitution"
+  after_specify:
+    enabled: false
+    message: "[Spec Kit] Add specification"
+  after_clarify:
+    enabled: false
+    message: "[Spec Kit] Clarify specification"
+  after_plan:
+    enabled: false
+    message: "[Spec Kit] Add implementation plan"
+  after_tasks:
+    enabled: false
+    message: "[Spec Kit] Add tasks"
+  after_implement:
+    enabled: false
+    message: "[Spec Kit] Implementation progress"
+  after_checklist:
+    enabled: false
+    message: "[Spec Kit] Add checklist"
+  after_analyze:
+    enabled: false
+    message: "[Spec Kit] Add analysis report"
+  after_taskstoissues:
+    enabled: false
+    message: "[Spec Kit] Sync tasks to issues"

+ 140 - 0
.specify/extensions/git/extension.yml

@@ -0,0 +1,140 @@
+schema_version: "1.0"
+
+extension:
+  id: git
+  name: "Git Branching Workflow"
+  version: "1.0.0"
+  description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
+  author: spec-kit-core
+  repository: https://github.com/github/spec-kit
+  license: MIT
+
+requires:
+  speckit_version: ">=0.2.0"
+  tools:
+    - name: git
+      required: false
+
+provides:
+  commands:
+    - name: speckit.git.feature
+      file: commands/speckit.git.feature.md
+      description: "Create a feature branch with sequential or timestamp numbering"
+    - name: speckit.git.validate
+      file: commands/speckit.git.validate.md
+      description: "Validate current branch follows feature branch naming conventions"
+    - name: speckit.git.remote
+      file: commands/speckit.git.remote.md
+      description: "Detect Git remote URL for GitHub integration"
+    - name: speckit.git.initialize
+      file: commands/speckit.git.initialize.md
+      description: "Initialize a Git repository with an initial commit"
+    - name: speckit.git.commit
+      file: commands/speckit.git.commit.md
+      description: "Auto-commit changes after a Spec Kit command completes"
+
+  config:
+    - name: "git-config.yml"
+      template: "config-template.yml"
+      description: "Git branching configuration"
+      required: false
+
+hooks:
+  before_constitution:
+    command: speckit.git.initialize
+    optional: false
+    description: "Initialize Git repository before constitution setup"
+  before_specify:
+    command: speckit.git.feature
+    optional: false
+    description: "Create feature branch before specification"
+  before_clarify:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before clarification?"
+    description: "Auto-commit before spec clarification"
+  before_plan:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before planning?"
+    description: "Auto-commit before implementation planning"
+  before_tasks:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before task generation?"
+    description: "Auto-commit before task generation"
+  before_implement:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before implementation?"
+    description: "Auto-commit before implementation"
+  before_checklist:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before checklist?"
+    description: "Auto-commit before checklist generation"
+  before_analyze:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before analysis?"
+    description: "Auto-commit before analysis"
+  before_taskstoissues:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit outstanding changes before issue sync?"
+    description: "Auto-commit before tasks-to-issues conversion"
+  after_constitution:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit constitution changes?"
+    description: "Auto-commit after constitution update"
+  after_specify:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit specification changes?"
+    description: "Auto-commit after specification"
+  after_clarify:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit clarification changes?"
+    description: "Auto-commit after spec clarification"
+  after_plan:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit plan changes?"
+    description: "Auto-commit after implementation planning"
+  after_tasks:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit task changes?"
+    description: "Auto-commit after task generation"
+  after_implement:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit implementation changes?"
+    description: "Auto-commit after implementation"
+  after_checklist:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit checklist changes?"
+    description: "Auto-commit after checklist generation"
+  after_analyze:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit analysis results?"
+    description: "Auto-commit after analysis"
+  after_taskstoissues:
+    command: speckit.git.commit
+    optional: true
+    prompt: "Commit after syncing issues?"
+    description: "Auto-commit after tasks-to-issues conversion"
+
+tags:
+  - "git"
+  - "branching"
+  - "workflow"
+
+config:
+  defaults:
+    branch_numbering: sequential
+    init_commit_message: "[Spec Kit] Initial commit"

+ 62 - 0
.specify/extensions/git/git-config.yml

@@ -0,0 +1,62 @@
+# Git Branching Workflow Extension Configuration
+# Copied to .specify/extensions/git/git-config.yml on install
+
+# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
+branch_numbering: sequential
+
+# Commit message used by `git commit` during repository initialization
+init_commit_message: "[Spec Kit] Initial commit"
+
+# Auto-commit before/after core commands.
+# Set "default" to enable for all commands, then override per-command.
+# Each key can be true/false. Message is customizable per-command.
+auto_commit:
+  default: false
+  before_clarify:
+    enabled: false
+    message: "[Spec Kit] Save progress before clarification"
+  before_plan:
+    enabled: false
+    message: "[Spec Kit] Save progress before planning"
+  before_tasks:
+    enabled: false
+    message: "[Spec Kit] Save progress before task generation"
+  before_implement:
+    enabled: false
+    message: "[Spec Kit] Save progress before implementation"
+  before_checklist:
+    enabled: false
+    message: "[Spec Kit] Save progress before checklist"
+  before_analyze:
+    enabled: false
+    message: "[Spec Kit] Save progress before analysis"
+  before_taskstoissues:
+    enabled: false
+    message: "[Spec Kit] Save progress before issue sync"
+  after_constitution:
+    enabled: false
+    message: "[Spec Kit] Add project constitution"
+  after_specify:
+    enabled: false
+    message: "[Spec Kit] Add specification"
+  after_clarify:
+    enabled: false
+    message: "[Spec Kit] Clarify specification"
+  after_plan:
+    enabled: false
+    message: "[Spec Kit] Add implementation plan"
+  after_tasks:
+    enabled: false
+    message: "[Spec Kit] Add tasks"
+  after_implement:
+    enabled: false
+    message: "[Spec Kit] Implementation progress"
+  after_checklist:
+    enabled: false
+    message: "[Spec Kit] Add checklist"
+  after_analyze:
+    enabled: false
+    message: "[Spec Kit] Add analysis report"
+  after_taskstoissues:
+    enabled: false
+    message: "[Spec Kit] Sync tasks to issues"

+ 140 - 0
.specify/extensions/git/scripts/bash/auto-commit.sh

@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+# Git extension: auto-commit.sh
+# Automatically commit changes after a Spec Kit command completes.
+# Checks per-command config keys in git-config.yml before committing.
+#
+# Usage: auto-commit.sh <event_name>
+#   e.g.: auto-commit.sh after_specify
+
+set -e
+
+EVENT_NAME="${1:-}"
+if [ -z "$EVENT_NAME" ]; then
+    echo "Usage: $0 <event_name>" >&2
+    exit 1
+fi
+
+SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+_find_project_root() {
+    local dir="$1"
+    while [ "$dir" != "/" ]; do
+        if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
+            echo "$dir"
+            return 0
+        fi
+        dir="$(dirname "$dir")"
+    done
+    return 1
+}
+
+REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
+cd "$REPO_ROOT"
+
+# Check if git is available
+if ! command -v git >/dev/null 2>&1; then
+    echo "[specify] Warning: Git not found; skipped auto-commit" >&2
+    exit 0
+fi
+
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+    echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
+    exit 0
+fi
+
+# Read per-command config from git-config.yml
+_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
+_enabled=false
+_commit_msg=""
+
+if [ -f "$_config_file" ]; then
+    # Parse the auto_commit section for this event.
+    # Look for auto_commit.<event_name>.enabled and .message
+    # Also check auto_commit.default as fallback.
+    _in_auto_commit=false
+    _in_event=false
+    _default_enabled=false
+
+    while IFS= read -r _line; do
+        # Detect auto_commit: section
+        if echo "$_line" | grep -q '^auto_commit:'; then
+            _in_auto_commit=true
+            _in_event=false
+            continue
+        fi
+
+        # Exit auto_commit section on next top-level key
+        if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
+            break
+        fi
+
+        if $_in_auto_commit; then
+            # Check default key
+            if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
+                _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
+                [ "$_val" = "true" ] && _default_enabled=true
+            fi
+
+            # Detect our event subsection
+            if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
+                _in_event=true
+                continue
+            fi
+
+            # Inside our event subsection
+            if $_in_event; then
+                # Exit on next sibling key (same indent level as event name)
+                if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
+                    _in_event=false
+                    continue
+                fi
+                if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
+                    _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
+                    [ "$_val" = "true" ] && _enabled=true
+                    [ "$_val" = "false" ] && _enabled=false
+                fi
+                if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
+                    _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
+                fi
+            fi
+        fi
+    done < "$_config_file"
+
+    # If event-specific key not found, use default
+    if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
+        # Only use default if the event wasn't explicitly set to false
+        # Check if event section existed at all
+        if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
+            _enabled=true
+        fi
+    fi
+else
+    # No config file — auto-commit disabled by default
+    exit 0
+fi
+
+if [ "$_enabled" != "true" ]; then
+    exit 0
+fi
+
+# Check if there are changes to commit
+if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
+    echo "[specify] No changes to commit after $EVENT_NAME" >&2
+    exit 0
+fi
+
+# Derive a human-readable command name from the event
+# e.g., after_specify -> specify, before_plan -> plan
+_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
+_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
+
+# Use custom message if configured, otherwise default
+if [ -z "$_commit_msg" ]; then
+    _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
+fi
+
+# Stage and commit
+_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
+_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
+
+echo "[OK] Changes committed ${_phase} ${_command_name}" >&2

+ 453 - 0
.specify/extensions/git/scripts/bash/create-new-feature.sh

@@ -0,0 +1,453 @@
+#!/usr/bin/env bash
+# Git extension: create-new-feature.sh
+# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
+# Sources common.sh from the project's installed scripts, falling back to
+# git-common.sh for minimal git helpers.
+
+set -e
+
+JSON_MODE=false
+DRY_RUN=false
+ALLOW_EXISTING=false
+SHORT_NAME=""
+BRANCH_NUMBER=""
+USE_TIMESTAMP=false
+ARGS=()
+i=1
+while [ $i -le $# ]; do
+    arg="${!i}"
+    case "$arg" in
+        --json)
+            JSON_MODE=true
+            ;;
+        --dry-run)
+            DRY_RUN=true
+            ;;
+        --allow-existing-branch)
+            ALLOW_EXISTING=true
+            ;;
+        --short-name)
+            if [ $((i + 1)) -gt $# ]; then
+                echo 'Error: --short-name requires a value' >&2
+                exit 1
+            fi
+            i=$((i + 1))
+            next_arg="${!i}"
+            if [[ "$next_arg" == --* ]]; then
+                echo 'Error: --short-name requires a value' >&2
+                exit 1
+            fi
+            SHORT_NAME="$next_arg"
+            ;;
+        --number)
+            if [ $((i + 1)) -gt $# ]; then
+                echo 'Error: --number requires a value' >&2
+                exit 1
+            fi
+            i=$((i + 1))
+            next_arg="${!i}"
+            if [[ "$next_arg" == --* ]]; then
+                echo 'Error: --number requires a value' >&2
+                exit 1
+            fi
+            BRANCH_NUMBER="$next_arg"
+            if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
+                echo 'Error: --number must be a non-negative integer' >&2
+                exit 1
+            fi
+            ;;
+        --timestamp)
+            USE_TIMESTAMP=true
+            ;;
+        --help|-h)
+            echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
+            echo ""
+            echo "Options:"
+            echo "  --json              Output in JSON format"
+            echo "  --dry-run           Compute branch name without creating the branch"
+            echo "  --allow-existing-branch  Switch to branch if it already exists instead of failing"
+            echo "  --short-name <name> Provide a custom short name (2-4 words) for the branch"
+            echo "  --number N          Specify branch number manually (overrides auto-detection)"
+            echo "  --timestamp         Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
+            echo "  --help, -h          Show this help message"
+            echo ""
+            echo "Environment variables:"
+            echo "  GIT_BRANCH_NAME     Use this exact branch name, bypassing all prefix/suffix generation"
+            echo ""
+            echo "Examples:"
+            echo "  $0 'Add user authentication system' --short-name 'user-auth'"
+            echo "  $0 'Implement OAuth2 integration for API' --number 5"
+            echo "  $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
+            echo "  GIT_BRANCH_NAME=my-branch $0 'feature description'"
+            exit 0
+            ;;
+        *)
+            ARGS+=("$arg")
+            ;;
+    esac
+    i=$((i + 1))
+done
+
+FEATURE_DESCRIPTION="${ARGS[*]}"
+if [ -z "$FEATURE_DESCRIPTION" ]; then
+    echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
+    exit 1
+fi
+
+# Trim whitespace and validate description is not empty
+FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
+if [ -z "$FEATURE_DESCRIPTION" ]; then
+    echo "Error: Feature description cannot be empty or contain only whitespace" >&2
+    exit 1
+fi
+
+# Function to get highest number from specs directory
+get_highest_from_specs() {
+    local specs_dir="$1"
+    local highest=0
+
+    if [ -d "$specs_dir" ]; then
+        for dir in "$specs_dir"/*; do
+            [ -d "$dir" ] || continue
+            dirname=$(basename "$dir")
+            # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
+            if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
+                number=$(echo "$dirname" | grep -Eo '^[0-9]+')
+                number=$((10#$number))
+                if [ "$number" -gt "$highest" ]; then
+                    highest=$number
+                fi
+            fi
+        done
+    fi
+
+    echo "$highest"
+}
+
+# Function to get highest number from git branches
+get_highest_from_branches() {
+    git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
+}
+
+# Extract the highest sequential feature number from a list of ref names (one per line).
+_extract_highest_number() {
+    local highest=0
+    while IFS= read -r name; do
+        [ -z "$name" ] && continue
+        if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
+            number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
+            number=$((10#$number))
+            if [ "$number" -gt "$highest" ]; then
+                highest=$number
+            fi
+        fi
+    done
+    echo "$highest"
+}
+
+# Function to get highest number from remote branches without fetching (side-effect-free)
+get_highest_from_remote_refs() {
+    local highest=0
+
+    for remote in $(git remote 2>/dev/null); do
+        local remote_highest
+        remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
+        if [ "$remote_highest" -gt "$highest" ]; then
+            highest=$remote_highest
+        fi
+    done
+
+    echo "$highest"
+}
+
+# Function to check existing branches and return next available number.
+check_existing_branches() {
+    local specs_dir="$1"
+    local skip_fetch="${2:-false}"
+
+    if [ "$skip_fetch" = true ]; then
+        local highest_remote=$(get_highest_from_remote_refs)
+        local highest_branch=$(get_highest_from_branches)
+        if [ "$highest_remote" -gt "$highest_branch" ]; then
+            highest_branch=$highest_remote
+        fi
+    else
+        git fetch --all --prune >/dev/null 2>&1 || true
+        local highest_branch=$(get_highest_from_branches)
+    fi
+
+    local highest_spec=$(get_highest_from_specs "$specs_dir")
+
+    local max_num=$highest_branch
+    if [ "$highest_spec" -gt "$max_num" ]; then
+        max_num=$highest_spec
+    fi
+
+    echo $((max_num + 1))
+}
+
+# Function to clean and format a branch name
+clean_branch_name() {
+    local name="$1"
+    echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
+}
+
+# ---------------------------------------------------------------------------
+# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
+#
+# Search locations in priority order:
+#  1. .specify/scripts/bash/common.sh under the project root (installed project)
+#  2. scripts/bash/common.sh under the project root (source checkout fallback)
+#  3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
+# ---------------------------------------------------------------------------
+SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Find project root by walking up from the script location
+_find_project_root() {
+    local dir="$1"
+    while [ "$dir" != "/" ]; do
+        if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
+            echo "$dir"
+            return 0
+        fi
+        dir="$(dirname "$dir")"
+    done
+    return 1
+}
+
+_common_loaded=false
+_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
+
+if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
+    source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
+    _common_loaded=true
+elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
+    source "$_PROJECT_ROOT/scripts/bash/common.sh"
+    _common_loaded=true
+elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
+    source "$SCRIPT_DIR/git-common.sh"
+    _common_loaded=true
+fi
+
+if [ "$_common_loaded" != "true" ]; then
+    echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
+    exit 1
+fi
+
+# Resolve repository root
+if type get_repo_root >/dev/null 2>&1; then
+    REPO_ROOT=$(get_repo_root)
+elif git rev-parse --show-toplevel >/dev/null 2>&1; then
+    REPO_ROOT=$(git rev-parse --show-toplevel)
+elif [ -n "$_PROJECT_ROOT" ]; then
+    REPO_ROOT="$_PROJECT_ROOT"
+else
+    echo "Error: Could not determine repository root." >&2
+    exit 1
+fi
+
+# Check if git is available at this repo root
+if type has_git >/dev/null 2>&1; then
+    if has_git "$REPO_ROOT"; then
+        HAS_GIT=true
+    else
+        HAS_GIT=false
+    fi
+elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+    HAS_GIT=true
+else
+    HAS_GIT=false
+fi
+
+cd "$REPO_ROOT"
+
+SPECS_DIR="$REPO_ROOT/specs"
+
+# Function to generate branch name with stop word filtering
+generate_branch_name() {
+    local description="$1"
+
+    local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
+
+    local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
+
+    local meaningful_words=()
+    for word in $clean_name; do
+        [ -z "$word" ] && continue
+        if ! echo "$word" | grep -qiE "$stop_words"; then
+            if [ ${#word} -ge 3 ]; then
+                meaningful_words+=("$word")
+            elif echo "$description" | grep -qw -- "${word^^}"; then
+                meaningful_words+=("$word")
+            fi
+        fi
+    done
+
+    if [ ${#meaningful_words[@]} -gt 0 ]; then
+        local max_words=3
+        if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
+
+        local result=""
+        local count=0
+        for word in "${meaningful_words[@]}"; do
+            if [ $count -ge $max_words ]; then break; fi
+            if [ -n "$result" ]; then result="$result-"; fi
+            result="$result$word"
+            count=$((count + 1))
+        done
+        echo "$result"
+    else
+        local cleaned=$(clean_branch_name "$description")
+        echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
+    fi
+}
+
+# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
+if [ -n "${GIT_BRANCH_NAME:-}" ]; then
+    BRANCH_NAME="$GIT_BRANCH_NAME"
+    # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
+    # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
+    if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
+        FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
+        BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
+    elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
+        FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
+        BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
+    else
+        FEATURE_NUM="$BRANCH_NAME"
+        BRANCH_SUFFIX="$BRANCH_NAME"
+    fi
+else
+    # Generate branch name
+    if [ -n "$SHORT_NAME" ]; then
+        BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
+    else
+        BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
+    fi
+
+    # Warn if --number and --timestamp are both specified
+    if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
+        >&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
+        BRANCH_NUMBER=""
+    fi
+
+    # Determine branch prefix
+    if [ "$USE_TIMESTAMP" = true ]; then
+        FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
+        BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
+    else
+        if [ -z "$BRANCH_NUMBER" ]; then
+            if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
+                BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
+            elif [ "$DRY_RUN" = true ]; then
+                HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
+                BRANCH_NUMBER=$((HIGHEST + 1))
+            elif [ "$HAS_GIT" = true ]; then
+                BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
+            else
+                HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
+                BRANCH_NUMBER=$((HIGHEST + 1))
+            fi
+        fi
+
+        FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
+        BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
+    fi
+fi
+
+# GitHub enforces a 244-byte limit on branch names
+MAX_BRANCH_LENGTH=244
+_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
+BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
+if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
+    >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
+    exit 1
+elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
+    PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
+    MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
+
+    TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
+    TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
+
+    ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
+    BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
+
+    >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
+    >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
+    >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
+fi
+
+if [ "$DRY_RUN" != true ]; then
+    if [ "$HAS_GIT" = true ]; then
+        branch_create_error=""
+        if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
+            current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+            if git branch --list "$BRANCH_NAME" | grep -q .; then
+                if [ "$ALLOW_EXISTING" = true ]; then
+                    if [ "$current_branch" = "$BRANCH_NAME" ]; then
+                        :
+                    elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
+                        >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
+                        if [ -n "$switch_branch_error" ]; then
+                            >&2 printf '%s\n' "$switch_branch_error"
+                        fi
+                        exit 1
+                    fi
+                elif [ "$USE_TIMESTAMP" = true ]; then
+                    >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
+                    exit 1
+                else
+                    >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
+                    exit 1
+                fi
+            else
+                >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
+                if [ -n "$branch_create_error" ]; then
+                    >&2 printf '%s\n' "$branch_create_error"
+                else
+                    >&2 echo "Please check your git configuration and try again."
+                fi
+                exit 1
+            fi
+        fi
+    else
+        >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
+    fi
+
+    printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
+fi
+
+if $JSON_MODE; then
+    if command -v jq >/dev/null 2>&1; then
+        if [ "$DRY_RUN" = true ]; then
+            jq -cn \
+                --arg branch_name "$BRANCH_NAME" \
+                --arg feature_num "$FEATURE_NUM" \
+                '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
+        else
+            jq -cn \
+                --arg branch_name "$BRANCH_NAME" \
+                --arg feature_num "$FEATURE_NUM" \
+                '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
+        fi
+    else
+        if type json_escape >/dev/null 2>&1; then
+            _je_branch=$(json_escape "$BRANCH_NAME")
+            _je_num=$(json_escape "$FEATURE_NUM")
+        else
+            _je_branch="$BRANCH_NAME"
+            _je_num="$FEATURE_NUM"
+        fi
+        if [ "$DRY_RUN" = true ]; then
+            printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
+        else
+            printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
+        fi
+    fi
+else
+    echo "BRANCH_NAME: $BRANCH_NAME"
+    echo "FEATURE_NUM: $FEATURE_NUM"
+    if [ "$DRY_RUN" != true ]; then
+        printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
+    fi
+fi

+ 54 - 0
.specify/extensions/git/scripts/bash/git-common.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Git-specific common functions for the git extension.
+# Extracted from scripts/bash/common.sh — contains only git-specific
+# branch validation and detection logic.
+
+# Check if we have git available at the repo root
+has_git() {
+    local repo_root="${1:-$(pwd)}"
+    { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
+        command -v git >/dev/null 2>&1 && \
+        git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
+}
+
+# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
+# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
+spec_kit_effective_branch_name() {
+    local raw="$1"
+    if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
+        printf '%s\n' "${BASH_REMATCH[2]}"
+    else
+        printf '%s\n' "$raw"
+    fi
+}
+
+# Validate that a branch name matches the expected feature branch pattern.
+# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
+# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
+check_feature_branch() {
+    local raw="$1"
+    local has_git_repo="$2"
+
+    # For non-git repos, we can't enforce branch naming but still provide output
+    if [[ "$has_git_repo" != "true" ]]; then
+        echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
+        return 0
+    fi
+
+    local branch
+    branch=$(spec_kit_effective_branch_name "$raw")
+
+    # Accept sequential prefix (3+ digits) but exclude malformed timestamps
+    # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
+    local is_sequential=false
+    if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
+        is_sequential=true
+    fi
+    if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
+        echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
+        echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
+        return 1
+    fi
+
+    return 0
+}

+ 54 - 0
.specify/extensions/git/scripts/bash/initialize-repo.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Git extension: initialize-repo.sh
+# Initialize a Git repository with an initial commit.
+# Customizable — replace this script to add .gitignore templates,
+# default branch config, git-flow, LFS, signing, etc.
+
+set -e
+
+SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Find project root
+_find_project_root() {
+    local dir="$1"
+    while [ "$dir" != "/" ]; do
+        if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
+            echo "$dir"
+            return 0
+        fi
+        dir="$(dirname "$dir")"
+    done
+    return 1
+}
+
+REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
+cd "$REPO_ROOT"
+
+# Read commit message from extension config, fall back to default
+COMMIT_MSG="[Spec Kit] Initial commit"
+_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
+if [ -f "$_config_file" ]; then
+    _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
+    if [ -n "$_msg" ]; then
+        COMMIT_MSG="$_msg"
+    fi
+fi
+
+# Check if git is available
+if ! command -v git >/dev/null 2>&1; then
+    echo "[specify] Warning: Git not found; skipped repository initialization" >&2
+    exit 0
+fi
+
+# Check if already a git repo
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+    echo "[specify] Git repository already initialized; skipping" >&2
+    exit 0
+fi
+
+# Initialize
+_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
+_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
+_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
+
+echo "✓ Git repository initialized" >&2

+ 169 - 0
.specify/extensions/git/scripts/powershell/auto-commit.ps1

@@ -0,0 +1,169 @@
+#!/usr/bin/env pwsh
+# Git extension: auto-commit.ps1
+# Automatically commit changes after a Spec Kit command completes.
+# Checks per-command config keys in git-config.yml before committing.
+#
+# Usage: auto-commit.ps1 <event_name>
+#   e.g.: auto-commit.ps1 after_specify
+param(
+    [Parameter(Position = 0, Mandatory = $true)]
+    [string]$EventName
+)
+$ErrorActionPreference = 'Stop'
+
+function Find-ProjectRoot {
+    param([string]$StartDir)
+    $current = Resolve-Path $StartDir
+    while ($true) {
+        foreach ($marker in @('.specify', '.git')) {
+            if (Test-Path (Join-Path $current $marker)) {
+                return $current
+            }
+        }
+        $parent = Split-Path $current -Parent
+        if ($parent -eq $current) { return $null }
+        $current = $parent
+    }
+}
+
+$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
+if (-not $repoRoot) { $repoRoot = Get-Location }
+Set-Location $repoRoot
+
+# Check if git is available
+if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
+    Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
+    exit 0
+}
+
+# Temporarily relax ErrorActionPreference so git stderr warnings
+# (e.g. CRLF notices on Windows) do not become terminating errors.
+$savedEAP = $ErrorActionPreference
+$ErrorActionPreference = 'Continue'
+try {
+    git rev-parse --is-inside-work-tree 2>$null | Out-Null
+    $isRepo = $LASTEXITCODE -eq 0
+} finally {
+    $ErrorActionPreference = $savedEAP
+}
+if (-not $isRepo) {
+    Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
+    exit 0
+}
+
+# Read per-command config from git-config.yml
+$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
+$enabled = $false
+$commitMsg = ""
+
+if (Test-Path $configFile) {
+    # Parse YAML to find auto_commit section
+    $inAutoCommit = $false
+    $inEvent = $false
+    $defaultEnabled = $false
+
+    foreach ($line in Get-Content $configFile) {
+        # Detect auto_commit: section
+        if ($line -match '^auto_commit:') {
+            $inAutoCommit = $true
+            $inEvent = $false
+            continue
+        }
+
+        # Exit auto_commit section on next top-level key
+        if ($inAutoCommit -and $line -match '^[a-z]') {
+            break
+        }
+
+        if ($inAutoCommit) {
+            # Check default key
+            if ($line -match '^\s+default:\s*(.+)$') {
+                $val = $matches[1].Trim().ToLower()
+                if ($val -eq 'true') { $defaultEnabled = $true }
+            }
+
+            # Detect our event subsection
+            if ($line -match "^\s+${EventName}:") {
+                $inEvent = $true
+                continue
+            }
+
+            # Inside our event subsection
+            if ($inEvent) {
+                # Exit on next sibling key (2-space indent, not 4+)
+                if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
+                    $inEvent = $false
+                    continue
+                }
+                if ($line -match '\s+enabled:\s*(.+)$') {
+                    $val = $matches[1].Trim().ToLower()
+                    if ($val -eq 'true') { $enabled = $true }
+                    if ($val -eq 'false') { $enabled = $false }
+                }
+                if ($line -match '\s+message:\s*(.+)$') {
+                    $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
+                }
+            }
+        }
+    }
+
+    # If event-specific key not found, use default
+    if (-not $enabled -and $defaultEnabled) {
+        $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
+        if (-not $hasEventKey) {
+            $enabled = $true
+        }
+    }
+} else {
+    # No config file — auto-commit disabled by default
+    exit 0
+}
+
+if (-not $enabled) {
+    exit 0
+}
+
+# Check if there are changes to commit
+# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
+$savedEAP = $ErrorActionPreference
+$ErrorActionPreference = 'Continue'
+try {
+    git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
+    git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
+    $untracked = git ls-files --others --exclude-standard 2>$null
+} finally {
+    $ErrorActionPreference = $savedEAP
+}
+
+if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
+    Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
+    exit 0
+}
+
+# Derive a human-readable command name from the event
+$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
+$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
+
+# Use custom message if configured, otherwise default
+if (-not $commitMsg) {
+    $commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
+}
+
+# Stage and commit
+# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
+# while still allowing redirected error output to be captured for diagnostics.
+$savedEAP = $ErrorActionPreference
+$ErrorActionPreference = 'Continue'
+try {
+    $out = git add . 2>&1 | Out-String
+    if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
+    $out = git commit -q -m $commitMsg 2>&1 | Out-String
+    if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
+} catch {
+    Write-Warning "[specify] Error: $_"
+    exit 1
+} finally {
+    $ErrorActionPreference = $savedEAP
+}
+
+Write-Host "[OK] Changes committed $phase $commandName"

+ 403 - 0
.specify/extensions/git/scripts/powershell/create-new-feature.ps1

@@ -0,0 +1,403 @@
+#!/usr/bin/env pwsh
+# Git extension: create-new-feature.ps1
+# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
+# Sources common.ps1 from the project's installed scripts, falling back to
+# git-common.ps1 for minimal git helpers.
+[CmdletBinding()]
+param(
+    [switch]$Json,
+    [switch]$AllowExistingBranch,
+    [switch]$DryRun,
+    [string]$ShortName,
+    [Parameter()]
+    [long]$Number = 0,
+    [switch]$Timestamp,
+    [switch]$Help,
+    [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
+    [string[]]$FeatureDescription
+)
+$ErrorActionPreference = 'Stop'
+
+if ($Help) {
+    Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
+    Write-Host ""
+    Write-Host "Options:"
+    Write-Host "  -Json               Output in JSON format"
+    Write-Host "  -DryRun             Compute branch name without creating the branch"
+    Write-Host "  -AllowExistingBranch  Switch to branch if it already exists instead of failing"
+    Write-Host "  -ShortName <name>   Provide a custom short name (2-4 words) for the branch"
+    Write-Host "  -Number N           Specify branch number manually (overrides auto-detection)"
+    Write-Host "  -Timestamp          Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
+    Write-Host "  -Help               Show this help message"
+    Write-Host ""
+    Write-Host "Environment variables:"
+    Write-Host "  GIT_BRANCH_NAME     Use this exact branch name, bypassing all prefix/suffix generation"
+    Write-Host ""
+    exit 0
+}
+
+if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
+    Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
+    exit 1
+}
+
+$featureDesc = ($FeatureDescription -join ' ').Trim()
+
+if ([string]::IsNullOrWhiteSpace($featureDesc)) {
+    Write-Error "Error: Feature description cannot be empty or contain only whitespace"
+    exit 1
+}
+
+function Get-HighestNumberFromSpecs {
+    param([string]$SpecsDir)
+
+    [long]$highest = 0
+    if (Test-Path $SpecsDir) {
+        Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
+            if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
+                [long]$num = 0
+                if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
+                    $highest = $num
+                }
+            }
+        }
+    }
+    return $highest
+}
+
+function Get-HighestNumberFromNames {
+    param([string[]]$Names)
+
+    [long]$highest = 0
+    foreach ($name in $Names) {
+        if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
+            [long]$num = 0
+            if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
+                $highest = $num
+            }
+        }
+    }
+    return $highest
+}
+
+function Get-HighestNumberFromBranches {
+    param()
+
+    try {
+        $branches = git branch -a 2>$null
+        if ($LASTEXITCODE -eq 0 -and $branches) {
+            $cleanNames = $branches | ForEach-Object {
+                $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
+            }
+            return Get-HighestNumberFromNames -Names $cleanNames
+        }
+    } catch {
+        Write-Verbose "Could not check Git branches: $_"
+    }
+    return 0
+}
+
+function Get-HighestNumberFromRemoteRefs {
+    [long]$highest = 0
+    try {
+        $remotes = git remote 2>$null
+        if ($remotes) {
+            foreach ($remote in $remotes) {
+                $env:GIT_TERMINAL_PROMPT = '0'
+                $refs = git ls-remote --heads $remote 2>$null
+                $env:GIT_TERMINAL_PROMPT = $null
+                if ($LASTEXITCODE -eq 0 -and $refs) {
+                    $refNames = $refs | ForEach-Object {
+                        if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
+                    } | Where-Object { $_ }
+                    $remoteHighest = Get-HighestNumberFromNames -Names $refNames
+                    if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
+                }
+            }
+        }
+    } catch {
+        Write-Verbose "Could not query remote refs: $_"
+    }
+    return $highest
+}
+
+function Get-NextBranchNumber {
+    param(
+        [string]$SpecsDir,
+        [switch]$SkipFetch
+    )
+
+    if ($SkipFetch) {
+        $highestBranch = Get-HighestNumberFromBranches
+        $highestRemote = Get-HighestNumberFromRemoteRefs
+        $highestBranch = [Math]::Max($highestBranch, $highestRemote)
+    } else {
+        try {
+            git fetch --all --prune 2>$null | Out-Null
+        } catch { }
+        $highestBranch = Get-HighestNumberFromBranches
+    }
+
+    $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
+    $maxNum = [Math]::Max($highestBranch, $highestSpec)
+    return $maxNum + 1
+}
+
+function ConvertTo-CleanBranchName {
+    param([string]$Name)
+    return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
+}
+
+# ---------------------------------------------------------------------------
+# Source common.ps1 from the project's installed scripts.
+# Search locations in priority order:
+#  1. .specify/scripts/powershell/common.ps1 under the project root
+#  2. scripts/powershell/common.ps1 under the project root (source checkout)
+#  3. git-common.ps1 next to this script (minimal fallback)
+# ---------------------------------------------------------------------------
+function Find-ProjectRoot {
+    param([string]$StartDir)
+    $current = Resolve-Path $StartDir
+    while ($true) {
+        foreach ($marker in @('.specify', '.git')) {
+            if (Test-Path (Join-Path $current $marker)) {
+                return $current
+            }
+        }
+        $parent = Split-Path $current -Parent
+        if ($parent -eq $current) { return $null }
+        $current = $parent
+    }
+}
+
+$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
+$commonLoaded = $false
+
+if ($projectRoot) {
+    $candidates = @(
+        (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
+        (Join-Path $projectRoot "scripts/powershell/common.ps1")
+    )
+    foreach ($candidate in $candidates) {
+        if (Test-Path $candidate) {
+            . $candidate
+            $commonLoaded = $true
+            break
+        }
+    }
+}
+
+if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
+    . "$PSScriptRoot/git-common.ps1"
+    $commonLoaded = $true
+}
+
+if (-not $commonLoaded) {
+    throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
+}
+
+# Resolve repository root
+if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
+    $repoRoot = Get-RepoRoot
+} elseif ($projectRoot) {
+    $repoRoot = $projectRoot
+} else {
+    throw "Could not determine repository root."
+}
+
+# Check if git is available
+if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
+    # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
+    # and git-common.ps1 (has -RepoRoot param with default).
+    $hasGit = Test-HasGit
+} else {
+    try {
+        git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
+        $hasGit = ($LASTEXITCODE -eq 0)
+    } catch {
+        $hasGit = $false
+    }
+}
+
+Set-Location $repoRoot
+
+$specsDir = Join-Path $repoRoot 'specs'
+
+function Get-BranchName {
+    param([string]$Description)
+
+    $stopWords = @(
+        'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
+        'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
+        'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
+        'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
+        'want', 'need', 'add', 'get', 'set'
+    )
+
+    $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
+    $words = $cleanName -split '\s+' | Where-Object { $_ }
+
+    $meaningfulWords = @()
+    foreach ($word in $words) {
+        if ($stopWords -contains $word) { continue }
+        if ($word.Length -ge 3) {
+            $meaningfulWords += $word
+        } elseif ($Description -match "\b$($word.ToUpper())\b") {
+            $meaningfulWords += $word
+        }
+    }
+
+    if ($meaningfulWords.Count -gt 0) {
+        $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
+        $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
+        return $result
+    } else {
+        $result = ConvertTo-CleanBranchName -Name $Description
+        $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
+        return [string]::Join('-', $fallbackWords)
+    }
+}
+
+# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
+if ($env:GIT_BRANCH_NAME) {
+    $branchName = $env:GIT_BRANCH_NAME
+    # Check 244-byte limit (UTF-8) for override names
+    $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
+    if ($branchNameUtf8ByteCount -gt 244) {
+        throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
+    }
+    # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
+    # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
+    if ($branchName -match '^(\d{8}-\d{6})-') {
+        $featureNum = $matches[1]
+    } elseif ($branchName -match '^(\d+)-') {
+        $featureNum = $matches[1]
+    } else {
+        $featureNum = $branchName
+    }
+} else {
+    if ($ShortName) {
+        $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
+    } else {
+        $branchSuffix = Get-BranchName -Description $featureDesc
+    }
+
+    if ($Timestamp -and $Number -ne 0) {
+        Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
+        $Number = 0
+    }
+
+    if ($Timestamp) {
+        $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
+        $branchName = "$featureNum-$branchSuffix"
+    } else {
+        if ($Number -eq 0) {
+            if ($DryRun -and $hasGit) {
+                $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
+            } elseif ($DryRun) {
+                $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
+            } elseif ($hasGit) {
+                $Number = Get-NextBranchNumber -SpecsDir $specsDir
+            } else {
+                $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
+            }
+        }
+
+        $featureNum = ('{0:000}' -f $Number)
+        $branchName = "$featureNum-$branchSuffix"
+    }
+}
+
+$maxBranchLength = 244
+if ($branchName.Length -gt $maxBranchLength) {
+    $prefixLength = $featureNum.Length + 1
+    $maxSuffixLength = $maxBranchLength - $prefixLength
+
+    $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
+    $truncatedSuffix = $truncatedSuffix -replace '-$', ''
+
+    $originalBranchName = $branchName
+    $branchName = "$featureNum-$truncatedSuffix"
+
+    Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
+    Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
+    Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
+}
+
+if (-not $DryRun) {
+    if ($hasGit) {
+        $branchCreated = $false
+        $branchCreateError = ''
+        try {
+            $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
+            if ($LASTEXITCODE -eq 0) {
+                $branchCreated = $true
+            }
+        } catch {
+            $branchCreateError = $_.Exception.Message
+        }
+
+        if (-not $branchCreated) {
+            $currentBranch = ''
+            try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
+            $existingBranch = git branch --list $branchName 2>$null
+            if ($existingBranch) {
+                if ($AllowExistingBranch) {
+                    if ($currentBranch -eq $branchName) {
+                        # Already on the target branch
+                    } else {
+                        $switchBranchError = git checkout -q $branchName 2>&1 | Out-String
+                        if ($LASTEXITCODE -ne 0) {
+                            if ($switchBranchError) {
+                                Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
+                            } else {
+                                Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
+                            }
+                            exit 1
+                        }
+                    }
+                } elseif ($Timestamp) {
+                    Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
+                    exit 1
+                } else {
+                    Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
+                    exit 1
+                }
+            } else {
+                if ($branchCreateError) {
+                    Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
+                } else {
+                    Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
+                }
+                exit 1
+            }
+        }
+    } else {
+        if ($Json) {
+            [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
+        } else {
+            Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
+        }
+    }
+
+    $env:SPECIFY_FEATURE = $branchName
+}
+
+if ($Json) {
+    $obj = [PSCustomObject]@{
+        BRANCH_NAME = $branchName
+        FEATURE_NUM = $featureNum
+        HAS_GIT = $hasGit
+    }
+    if ($DryRun) {
+        $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
+    }
+    $obj | ConvertTo-Json -Compress
+} else {
+    Write-Output "BRANCH_NAME: $branchName"
+    Write-Output "FEATURE_NUM: $featureNum"
+    Write-Output "HAS_GIT: $hasGit"
+    if (-not $DryRun) {
+        Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
+    }
+}

+ 51 - 0
.specify/extensions/git/scripts/powershell/git-common.ps1

@@ -0,0 +1,51 @@
+#!/usr/bin/env pwsh
+# Git-specific common functions for the git extension.
+# Extracted from scripts/powershell/common.ps1 — contains only git-specific
+# branch validation and detection logic.
+
+function Test-HasGit {
+    param([string]$RepoRoot = (Get-Location))
+    try {
+        if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
+        if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
+        git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
+        return ($LASTEXITCODE -eq 0)
+    } catch {
+        return $false
+    }
+}
+
+function Get-SpecKitEffectiveBranchName {
+    param([string]$Branch)
+    if ($Branch -match '^([^/]+)/([^/]+)$') {
+        return $Matches[2]
+    }
+    return $Branch
+}
+
+function Test-FeatureBranch {
+    param(
+        [string]$Branch,
+        [bool]$HasGit = $true
+    )
+
+    # For non-git repos, we can't enforce branch naming but still provide output
+    if (-not $HasGit) {
+        Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
+        return $true
+    }
+
+    $raw = $Branch
+    $Branch = Get-SpecKitEffectiveBranchName $raw
+
+    # Accept sequential prefix (3+ digits) but exclude malformed timestamps
+    # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
+    $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
+    $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
+    if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
+        [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
+        [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
+        return $false
+    }
+    return $true
+}

+ 69 - 0
.specify/extensions/git/scripts/powershell/initialize-repo.ps1

@@ -0,0 +1,69 @@
+#!/usr/bin/env pwsh
+# Git extension: initialize-repo.ps1
+# Initialize a Git repository with an initial commit.
+# Customizable — replace this script to add .gitignore templates,
+# default branch config, git-flow, LFS, signing, etc.
+$ErrorActionPreference = 'Stop'
+
+# Find project root
+function Find-ProjectRoot {
+    param([string]$StartDir)
+    $current = Resolve-Path $StartDir
+    while ($true) {
+        foreach ($marker in @('.specify', '.git')) {
+            if (Test-Path (Join-Path $current $marker)) {
+                return $current
+            }
+        }
+        $parent = Split-Path $current -Parent
+        if ($parent -eq $current) { return $null }
+        $current = $parent
+    }
+}
+
+$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
+if (-not $repoRoot) { $repoRoot = Get-Location }
+Set-Location $repoRoot
+
+# Read commit message from extension config, fall back to default
+$commitMsg = "[Spec Kit] Initial commit"
+$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
+if (Test-Path $configFile) {
+    foreach ($line in Get-Content $configFile) {
+        if ($line -match '^init_commit_message:\s*(.+)$') {
+            $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
+            if ($val) { $commitMsg = $val }
+            break
+        }
+    }
+}
+
+# Check if git is available
+if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
+    Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
+    exit 0
+}
+
+# Check if already a git repo
+try {
+    git rev-parse --is-inside-work-tree 2>$null | Out-Null
+    if ($LASTEXITCODE -eq 0) {
+        Write-Warning "[specify] Git repository already initialized; skipping"
+        exit 0
+    }
+} catch { }
+
+# Initialize
+try {
+    $out = git init -q 2>&1 | Out-String
+    if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
+    $out = git add . 2>&1 | Out-String
+    if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
+    $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
+    if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
+} catch {
+    Write-Warning "[specify] Error: $_"
+    exit 1
+}
+
+Write-Host "✓ Git repository initialized"

+ 2 - 2
.specify/init-options.json

@@ -2,9 +2,9 @@
   "ai": "claude",
   "ai": "claude",
   "ai_skills": true,
   "ai_skills": true,
   "branch_numbering": "sequential",
   "branch_numbering": "sequential",
+  "context_file": "CLAUDE.md",
   "here": true,
   "here": true,
   "integration": "claude",
   "integration": "claude",
-  "preset": null,
   "script": "ps",
   "script": "ps",
-  "speckit_version": "0.5.1.dev0"
+  "speckit_version": "0.8.12"
 }
 }

+ 12 - 4
.specify/integration.json

@@ -1,7 +1,15 @@
 {
 {
+  "version": "0.8.12",
+  "integration_state_schema": 1,
+  "installed_integrations": [
+    "claude"
+  ],
+  "integration_settings": {
+    "claude": {
+      "script": "ps",
+      "invoke_separator": "-"
+    }
+  },
   "integration": "claude",
   "integration": "claude",
-  "version": "0.5.1.dev0",
-  "scripts": {
-    "update-context": ".specify/integrations/claude/scripts/update-context.ps1"
-  }
+  "default_integration": "claude"
 }
 }

+ 11 - 13
.specify/integrations/claude.manifest.json

@@ -1,18 +1,16 @@
 {
 {
   "integration": "claude",
   "integration": "claude",
-  "version": "0.5.1.dev0",
-  "installed_at": "2026-04-28T01:07:03.106717+00:00",
+  "version": "0.8.12",
+  "installed_at": "2026-06-01T02:01:40.494115+00:00",
   "files": {
   "files": {
-    ".claude/skills/speckit-analyze/SKILL.md": "ee6710db85b3cef4adaa4890a78b40dcf05b959b91068694d8778d5b3ef1a669",
-    ".claude/skills/speckit-checklist/SKILL.md": "5d49113ce275f0e9ab75af133eb7d0bae43976c41a1c2f57e04c5cfd28d260cb",
-    ".claude/skills/speckit-clarify/SKILL.md": "46347a788d9baa1731f1de602fbd65fa6fe208c4d2f18b8534ab015abd3f8562",
-    ".claude/skills/speckit-constitution/SKILL.md": "0587314f660dc731c9c8798d6e7eb6e3fd22957fa466e989e4d8d02ee392acbf",
-    ".claude/skills/speckit-implement/SKILL.md": "da049e25017a1e3c8548cbf5809ca5617b48fa66b6af14fa18860a908c1d41c1",
-    ".claude/skills/speckit-plan/SKILL.md": "9a09a9ba95ed5f571f10f2dda039c10cf3cf539be27f7e3884f0d902be94b71e",
-    ".claude/skills/speckit-specify/SKILL.md": "f30c94d605e480a84621fffef2c9f547163537b6d00549fa32ac141b4a4e30c8",
-    ".claude/skills/speckit-tasks/SKILL.md": "4d28cdf0619b57bdc69f3b67da00cc23a622f63c04ef81cf84c3e84aaa5a24ef",
-    ".claude/skills/speckit-taskstoissues/SKILL.md": "c9273d9c412dd7905fa7883e0b4305ba6a6fe4eab46bb4c6ba232b45454a555d",
-    ".specify/integrations/claude/scripts/update-context.ps1": "8bce5081fe27ebf414d4eaf127d91b5540b00d24dde4fe1e303e8eb26ad5211a",
-    ".specify/integrations/claude/scripts/update-context.sh": "21a5aa3fc644f693a29d35975ce21e5a949cdc1d0258b11c21940754c3644fa6"
+    ".claude/skills/speckit-analyze/SKILL.md": "adb9c4baed1d0e27cebd08e506a7247c1357ee721b6ea340201553d5792e6c8a",
+    ".claude/skills/speckit-checklist/SKILL.md": "812b2a102310470141b313670a51e7f33fe4f673a3b8c3d97ddff4abce2e5fbd",
+    ".claude/skills/speckit-clarify/SKILL.md": "0ca2d6dd6eae86d0e02149b1c3df0b7770e5e5dcfc0482f2b7176b12f6f6457d",
+    ".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
+    ".claude/skills/speckit-implement/SKILL.md": "efeb6dc763bcf6ee5fe6732ce4d4243fe7c9c37f1db10ac39d8b0780ff9eebe0",
+    ".claude/skills/speckit-plan/SKILL.md": "5dbf517056a7df98de24835cb44c582b8d9b5c95950f917b129a740ca7f6448b",
+    ".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
+    ".claude/skills/speckit-tasks/SKILL.md": "6a8ca4d9d9948e4f50ed7614cd5a0c743b510fd98d6791de554db8f53a0626ed",
+    ".claude/skills/speckit-taskstoissues/SKILL.md": "ccacc12041d1e27d3afffc7a189ac3d17444836e10eeb4e128b272b5e8ae45cb"
   }
   }
 }
 }

+ 9 - 8
.specify/integrations/speckit.manifest.json

@@ -1,18 +1,19 @@
 {
 {
   "integration": "speckit",
   "integration": "speckit",
-  "version": "0.5.1.dev0",
+  "version": "0.8.12",
   "installed_at": "2026-04-28T01:07:03.130714+00:00",
   "installed_at": "2026-04-28T01:07:03.130714+00:00",
   "files": {
   "files": {
     ".specify/scripts/powershell/check-prerequisites.ps1": "bcb37804b0757c37799b65a9321c1d3fb7b7ddcab6703c55c5b9a142c9166bf1",
     ".specify/scripts/powershell/check-prerequisites.ps1": "bcb37804b0757c37799b65a9321c1d3fb7b7ddcab6703c55c5b9a142c9166bf1",
-    ".specify/scripts/powershell/common.ps1": "e6f40ffb201144fdb0cf252809cf5066e6510a5f015228f0b2e74e84ca590ed3",
-    ".specify/scripts/powershell/create-new-feature.ps1": "de971f5c649948654b99cebc8caf50ba553b016a3b886e97b1ee09d8d80e7233",
-    ".specify/scripts/powershell/setup-plan.ps1": "c13ec3f33330e74aa088979c69bf35fe641301aaa50b2c1203c10ce94bcad338",
+    ".specify/scripts/powershell/common.ps1": "916b3ea1e29ef626a353f0ec011f906c6afff6818a97d560a0321b8557d970ec",
+    ".specify/scripts/powershell/create-new-feature.ps1": "0a7fc929db81b6318205ab3506187762bae1c4d3b2184f15e903ca54b78511e2",
+    ".specify/scripts/powershell/setup-plan.ps1": "137af47d79085d2770714ece307ed8f56f0d4cb1e9ec0c17db5c5c0d0b8a8c4c",
     ".specify/scripts/powershell/update-agent-context.ps1": "42db3401c2869d8c6e4a77bf2e4d744afca9aff472cc2ae5b360aeaccc3c4e07",
     ".specify/scripts/powershell/update-agent-context.ps1": "42db3401c2869d8c6e4a77bf2e4d744afca9aff472cc2ae5b360aeaccc3c4e07",
     ".specify/templates/agent-file-template.md": "55ed438c2e861444ef22f45fe5238f3ebf0dc1cb6e53067d7232fbbf4ce82892",
     ".specify/templates/agent-file-template.md": "55ed438c2e861444ef22f45fe5238f3ebf0dc1cb6e53067d7232fbbf4ce82892",
-    ".specify/templates/checklist-template.md": "312eee8291dfa984b21f95ddd0ca778e7a1f0b3a64bfc470d79762a3e3f5d7b8",
+    ".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
     ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
     ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
-    ".specify/templates/plan-template.md": "873e84b226fe3d24afe28046931b20db9bbb9210366428dc958a515349ed6e68",
-    ".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
-    ".specify/templates/tasks-template.md": "5da92ac1fbf5be2f9018a5064497995bf3592761ccb6b3951503c63d851297e8"
+    ".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7",
+    ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
+    ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
+    ".specify/scripts/powershell/setup-tasks.ps1": "aa7b19077dc4f6feee1dae47f68dc3e6d8ac75adf572a949443f3322b753cfa2"
   }
   }
 }
 }

+ 381 - 6
.specify/scripts/powershell/common.ps1

@@ -127,6 +127,16 @@ function Test-HasGit {
     }
     }
 }
 }
 
 
+# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
+# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
+function Get-SpecKitEffectiveBranchName {
+    param([string]$Branch)
+    if ($Branch -match '^([^/]+)/([^/]+)$') {
+        return $Matches[2]
+    }
+    return $Branch
+}
+
 function Test-FeatureBranch {
 function Test-FeatureBranch {
     param(
     param(
         [string]$Branch,
         [string]$Branch,
@@ -138,29 +148,175 @@ function Test-FeatureBranch {
         Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
         Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
         return $true
         return $true
     }
     }
+
+    $raw = $Branch
+    $Branch = Get-SpecKitEffectiveBranchName $raw
     
     
     # Accept sequential prefix (3+ digits) but exclude malformed timestamps
     # Accept sequential prefix (3+ digits) but exclude malformed timestamps
     # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
     # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
     $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
     $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
     $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
     $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
     if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
     if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
-        Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
-        Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
+        [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
+        [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
         return $false
         return $false
     }
     }
     return $true
     return $true
 }
 }
 
 
-function Get-FeatureDir {
-    param([string]$RepoRoot, [string]$Branch)
-    Join-Path $RepoRoot "specs/$Branch"
+# True when .specify/feature.json pins an existing feature directory that matches the
+# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
+function Test-FeatureJsonMatchesFeatureDir {
+    param(
+        [Parameter(Mandatory = $true)][string]$RepoRoot,
+        [Parameter(Mandatory = $true)][string]$ActiveFeatureDir
+    )
+
+    $featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
+    if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
+        return $false
+    }
+
+    try {
+        $raw = Get-Content -LiteralPath $featureJson -Raw
+        $cfg = $raw | ConvertFrom-Json
+    } catch {
+        return $false
+    }
+
+    $fd = $cfg.feature_directory
+    if ([string]::IsNullOrWhiteSpace([string]$fd)) {
+        return $false
+    }
+
+    if (-not [System.IO.Path]::IsPathRooted($fd)) {
+        $fd = Join-Path $RepoRoot $fd
+    }
+
+    if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
+        return $false
+    }
+
+    # Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
+    # symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
+    # Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
+    $resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
+    if ($resolvedJson) {
+        $normJson = $resolvedJson.Path
+    } else {
+        $normJson = [System.IO.Path]::GetFullPath($fd)
+    }
+
+    $resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
+    if ($resolvedActive) {
+        $normActive = $resolvedActive.Path
+    } else {
+        $normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
+    }
+
+    # Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
+    # PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
+    # absence as "we're on Windows".
+    if ($null -ne $IsWindows) {
+        $onWindows = $IsWindows
+    } else {
+        $onWindows = $true
+    }
+
+    if ($onWindows) {
+        $comparison = [System.StringComparison]::OrdinalIgnoreCase
+    } else {
+        $comparison = [System.StringComparison]::Ordinal
+    }
+
+    return [string]::Equals($normJson, $normActive, $comparison)
+}
+
+# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
+function Find-FeatureDirByPrefix {
+    param(
+        [Parameter(Mandatory = $true)][string]$RepoRoot,
+        [Parameter(Mandatory = $true)][string]$Branch
+    )
+    $specsDir = Join-Path $RepoRoot 'specs'
+    $branchName = Get-SpecKitEffectiveBranchName $Branch
+
+    $prefix = $null
+    if ($branchName -match '^(\d{8}-\d{6})-') {
+        $prefix = $Matches[1]
+    } elseif ($branchName -match '^(\d{3,})-') {
+        $prefix = $Matches[1]
+    } else {
+        return (Join-Path $specsDir $branchName)
+    }
+
+    $dirMatches = @()
+    if (Test-Path -LiteralPath $specsDir -PathType Container) {
+        $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
+    }
+
+    if ($dirMatches.Count -eq 0) {
+        return (Join-Path $specsDir $branchName)
+    }
+    if ($dirMatches.Count -eq 1) {
+        return $dirMatches[0].FullName
+    }
+    $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
+    [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
+    [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
+    return $null
+}
+
+# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
+function Get-FeatureDirFromBranchPrefixOrExit {
+    param(
+        [Parameter(Mandatory = $true)][string]$RepoRoot,
+        [Parameter(Mandatory = $true)][string]$CurrentBranch
+    )
+    $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
+    if ($null -eq $resolved) {
+        [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
+        exit 1
+    }
+    return $resolved
 }
 }
 
 
 function Get-FeaturePathsEnv {
 function Get-FeaturePathsEnv {
     $repoRoot = Get-RepoRoot
     $repoRoot = Get-RepoRoot
     $currentBranch = Get-CurrentBranch
     $currentBranch = Get-CurrentBranch
     $hasGit = Test-HasGit
     $hasGit = Test-HasGit
-    $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
+
+    # Resolve feature directory.  Priority:
+    #   1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
+    #   2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
+    #   3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
+    $featureJson = Join-Path $repoRoot '.specify/feature.json'
+    if ($env:SPECIFY_FEATURE_DIRECTORY) {
+        $featureDir = $env:SPECIFY_FEATURE_DIRECTORY
+        # Normalize relative paths to absolute under repo root
+        if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
+            $featureDir = Join-Path $repoRoot $featureDir
+        }
+    } elseif (Test-Path $featureJson) {
+        $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
+        try {
+            $featureConfig = $featureJsonRaw | ConvertFrom-Json
+        } catch {
+            [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
+            exit 1
+        }
+        if ($featureConfig.feature_directory) {
+            $featureDir = $featureConfig.feature_directory
+            # Normalize relative paths to absolute under repo root
+            if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
+                $featureDir = Join-Path $repoRoot $featureDir
+            }
+        } else {
+            $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
+        }
+    } else {
+        $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
+    }
     
     
     [PSCustomObject]@{
     [PSCustomObject]@{
         REPO_ROOT     = $repoRoot
         REPO_ROOT     = $repoRoot
@@ -199,6 +355,21 @@ function Test-DirHasFiles {
     }
     }
 }
 }
 
 
+# Find a usable Python 3 executable (python3, python, or py -3).
+# Returns the command/arguments as an array, or $null if none found.
+function Get-Python3Command {
+    if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
+    if (Get-Command python -ErrorAction SilentlyContinue) {
+        $ver = & python --version 2>&1
+        if ($ver -match 'Python 3') { return @('python') }
+    }
+    if (Get-Command py -ErrorAction SilentlyContinue) {
+        $ver = & py -3 --version 2>&1
+        if ($ver -match 'Python 3') { return @('py', '-3') }
+    }
+    return $null
+}
+
 # Resolve a template name to a file path using the priority stack:
 # Resolve a template name to a file path using the priority stack:
 #   1. .specify/templates/overrides/
 #   1. .specify/templates/overrides/
 #   2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
 #   2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -227,6 +398,7 @@ function Resolve-Template {
                 $presets = $registryData.presets
                 $presets = $registryData.presets
                 if ($presets) {
                 if ($presets) {
                     $sortedPresets = $presets.PSObject.Properties |
                     $sortedPresets = $presets.PSObject.Properties |
+                        Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
                         Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
                         Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
                         ForEach-Object { $_.Name }
                         ForEach-Object { $_.Name }
                 }
                 }
@@ -266,3 +438,206 @@ function Resolve-Template {
     return $null
     return $null
 }
 }
 
 
+# Resolve a template name to composed content using composition strategies.
+# Reads strategy metadata from preset manifests and composes content
+# from multiple layers using prepend, append, or wrap strategies.
+function Resolve-TemplateContent {
+    param(
+        [Parameter(Mandatory=$true)][string]$TemplateName,
+        [Parameter(Mandatory=$true)][string]$RepoRoot
+    )
+
+    $base = Join-Path $RepoRoot '.specify/templates'
+
+    # Collect all layers (highest priority first)
+    $layerPaths = @()
+    $layerStrategies = @()
+
+    # Priority 1: Project overrides (always "replace")
+    $override = Join-Path $base "overrides/$TemplateName.md"
+    if (Test-Path $override) {
+        $layerPaths += $override
+        $layerStrategies += 'replace'
+    }
+
+    # Priority 2: Installed presets (sorted by priority from .registry)
+    $presetsDir = Join-Path $RepoRoot '.specify/presets'
+    if (Test-Path $presetsDir) {
+        $registryFile = Join-Path $presetsDir '.registry'
+        $sortedPresets = @()
+        if (Test-Path $registryFile) {
+            try {
+                $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
+                $presets = $registryData.presets
+                if ($presets) {
+                    $sortedPresets = $presets.PSObject.Properties |
+                        Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
+                        Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
+                        ForEach-Object { $_.Name }
+                }
+            } catch {
+                $sortedPresets = @()
+            }
+        }
+
+        if ($sortedPresets.Count -gt 0) {
+            $pyCmd = Get-Python3Command
+            if (-not $pyCmd) {
+                # Check if any preset has strategy fields that would be ignored
+                foreach ($pid in $sortedPresets) {
+                    $mf = Join-Path $presetsDir "$pid/preset.yml"
+                    if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
+                        Write-Warning "No Python 3 found; preset composition strategies will be ignored"
+                        break
+                    }
+                }
+            }
+            $yamlWarned = $false
+            foreach ($presetId in $sortedPresets) {
+                # Read strategy and file path from preset manifest
+                $strategy = 'replace'
+                $manifestFilePath = ''
+                $manifest = Join-Path $presetsDir "$presetId/preset.yml"
+                if ((Test-Path $manifest) -and $pyCmd) {
+                    try {
+                        # Use Python to parse YAML manifest for strategy and file path
+                        $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
+                        $pyStderrFile = [System.IO.Path]::GetTempFileName()
+                        $stratResult = & $pyCmd[0] @pyArgs -c @"
+import sys
+try:
+    import yaml
+except ImportError:
+    print('yaml_missing', file=sys.stderr)
+    print('replace\t')
+    sys.exit(0)
+try:
+    with open(sys.argv[1]) as f:
+        data = yaml.safe_load(f)
+    for t in data.get('provides', {}).get('templates', []):
+        if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
+            print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
+            sys.exit(0)
+    print('replace\t')
+except Exception:
+    print('replace\t')
+"@ $manifest $TemplateName 2>$pyStderrFile
+                        if ($stratResult) {
+                            $parts = $stratResult.Trim() -split "`t", 2
+                            $strategy = $parts[0].ToLowerInvariant()
+                            if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
+                        }
+                        if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
+                            Write-Warning "PyYAML not available; composition strategies may be ignored"
+                            $yamlWarned = $true
+                        }
+                        Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
+                    } catch {
+                        $strategy = 'replace'
+                        if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
+                    }
+                }
+                # Try manifest file path first, then convention path
+                $candidate = $null
+                if ($manifestFilePath) {
+                    # Reject absolute paths and parent traversal
+                    if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
+                        $manifestFilePath = ''
+                    }
+                }
+                if ($manifestFilePath) {
+                    $mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
+                    if (Test-Path $mf) { $candidate = $mf }
+                }
+                if (-not $candidate) {
+                    $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
+                    if (Test-Path $cf) { $candidate = $cf }
+                }
+                if ($candidate) {
+                    $layerPaths += $candidate
+                    $layerStrategies += $strategy
+                }
+            }
+        } else {
+            # Fallback: alphabetical directory order (no registry or parse failure)
+            foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
+                $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
+                if (Test-Path $candidate) {
+                    $layerPaths += $candidate
+                    $layerStrategies += 'replace'
+                }
+            }
+        }
+    }
+
+    # Priority 3: Extension-provided templates (always "replace")
+    $extDir = Join-Path $RepoRoot '.specify/extensions'
+    if (Test-Path $extDir) {
+        foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
+            $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
+            if (Test-Path $candidate) {
+                $layerPaths += $candidate
+                $layerStrategies += 'replace'
+            }
+        }
+    }
+
+    # Priority 4: Core templates (always "replace")
+    $core = Join-Path $base "$TemplateName.md"
+    if (Test-Path $core) {
+        $layerPaths += $core
+        $layerStrategies += 'replace'
+    }
+
+    if ($layerPaths.Count -eq 0) { return $null }
+
+    # If the top (highest-priority) layer is replace, it wins entirely —
+    # lower layers are irrelevant regardless of their strategies.
+    if ($layerStrategies[0] -eq 'replace') {
+        return (Get-Content $layerPaths[0] -Raw)
+    }
+
+    # Check if any layer uses a non-replace strategy
+    $hasComposition = $false
+    foreach ($s in $layerStrategies) {
+        if ($s -ne 'replace') { $hasComposition = $true; break }
+    }
+
+    if (-not $hasComposition) {
+        return (Get-Content $layerPaths[0] -Raw)
+    }
+
+    # Find the effective base: scan from highest priority (index 0) downward
+    # to find the nearest replace layer. Only compose layers above that base.
+    $baseIdx = -1
+    for ($i = 0; $i -lt $layerPaths.Count; $i++) {
+        if ($layerStrategies[$i] -eq 'replace') {
+            $baseIdx = $i
+            break
+        }
+    }
+    if ($baseIdx -lt 0) { return $null }
+
+    $content = Get-Content $layerPaths[$baseIdx] -Raw
+
+    for ($i = $baseIdx - 1; $i -ge 0; $i--) {
+        $path = $layerPaths[$i]
+        $strat = $layerStrategies[$i]
+        $layerContent = Get-Content $path -Raw
+
+        switch ($strat) {
+            'replace' { $content = $layerContent }
+            'prepend' { $content = "$layerContent`n`n$content" }
+            'append'  { $content = "$content`n`n$layerContent" }
+            'wrap'    {
+                if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
+                    throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
+                }
+                $content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
+            }
+            default { throw "Unknown strategy: $strat" }
+        }
+    }
+
+    return $content
+}

+ 10 - 3
.specify/scripts/powershell/create-new-feature.ps1

@@ -315,9 +315,13 @@ if (-not $DryRun) {
                         # Already on the target branch — nothing to do
                         # Already on the target branch — nothing to do
                     } else {
                     } else {
                         # Otherwise switch to the existing branch instead of failing.
                         # Otherwise switch to the existing branch instead of failing.
-                        git checkout -q $branchName 2>$null | Out-Null
+                        $switchBranchError = git checkout -q $branchName 2>&1 | Out-String
                         if ($LASTEXITCODE -ne 0) {
                         if ($LASTEXITCODE -ne 0) {
-                            Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
+                            if ($switchBranchError) {
+                                Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
+                            } else {
+                                Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
+                            }
                             exit 1
                             exit 1
                         }
                         }
                     }
                     }
@@ -346,7 +350,10 @@ if (-not $DryRun) {
     if (-not (Test-Path -PathType Leaf $specFile)) {
     if (-not (Test-Path -PathType Leaf $specFile)) {
         $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
         $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
         if ($template -and (Test-Path $template)) {
         if ($template -and (Test-Path $template)) {
-            Copy-Item $template $specFile -Force
+            # Read the template content and write it to the spec file with UTF-8 encoding without BOM
+            $content = [System.IO.File]::ReadAllText($template)
+            $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+            [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
         } else {
         } else {
             New-Item -ItemType File -Path $specFile -Force | Out-Null
             New-Item -ItemType File -Path $specFile -Force | Out-Null
         }
         }

+ 9 - 5
.specify/scripts/powershell/setup-plan.ps1

@@ -23,9 +23,11 @@ if ($Help) {
 # Get all paths and variables from common functions
 # Get all paths and variables from common functions
 $paths = Get-FeaturePathsEnv
 $paths = Get-FeaturePathsEnv
 
 
-# Check if we're on a proper feature branch (only for git repos)
-if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { 
-    exit 1 
+# If feature.json pins an existing feature directory, branch naming is not required.
+if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
+    if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
+        exit 1
+    }
 }
 }
 
 
 # Ensure the feature directory exists
 # Ensure the feature directory exists
@@ -34,8 +36,10 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
 # Copy plan template if it exists, otherwise note it or create empty file
 # Copy plan template if it exists, otherwise note it or create empty file
 $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
 $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
 if ($template -and (Test-Path $template)) { 
 if ($template -and (Test-Path $template)) { 
-    Copy-Item $template $paths.IMPL_PLAN -Force
-    Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
+    # Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
+    $content = [System.IO.File]::ReadAllText($template)
+    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+    [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
 } else {
 } else {
     Write-Warning "Plan template not found"
     Write-Warning "Plan template not found"
     # Create a basic plan file if template doesn't exist
     # Create a basic plan file if template doesn't exist

+ 74 - 0
.specify/scripts/powershell/setup-tasks.ps1

@@ -0,0 +1,74 @@
+#!/usr/bin/env pwsh
+
+[CmdletBinding()]
+param(
+    [switch]$Json,
+    [switch]$Help
+)
+
+$ErrorActionPreference = 'Stop'
+
+if ($Help) {
+    Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
+    exit 0
+}
+
+# Source common functions
+. "$PSScriptRoot/common.ps1"
+
+# Get feature paths and validate branch
+$paths = Get-FeaturePathsEnv
+
+# If feature.json pins an existing feature directory, branch naming is not required.
+if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
+    if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
+        exit 1
+    }
+}
+
+if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
+    [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
+    [Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.")
+    exit 1
+}
+
+if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
+    [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
+    [Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
+    exit 1
+}
+
+# Build available docs list
+$docs = @()
+if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
+if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
+if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
+    $docs += 'contracts/'
+}
+if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
+
+# Resolve tasks template through override stack
+$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
+if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
+    $expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
+    [Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
+    exit 1
+}
+$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
+
+# Output results
+if ($Json) {
+    [PSCustomObject]@{
+        FEATURE_DIR    = $paths.FEATURE_DIR
+        AVAILABLE_DOCS = $docs
+        TASKS_TEMPLATE = $tasksTemplate
+    } | ConvertTo-Json -Compress
+} else {
+    Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
+    Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
+    Write-Output "AVAILABLE_DOCS:"
+    Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
+    Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
+    Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
+    Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
+}

+ 2 - 2
.specify/templates/checklist-template.md

@@ -4,13 +4,13 @@
 **Created**: [DATE]
 **Created**: [DATE]
 **Feature**: [Link to spec.md or relevant documentation]
 **Feature**: [Link to spec.md or relevant documentation]
 
 
-**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
+**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
 
 
 <!-- 
 <!-- 
   ============================================================================
   ============================================================================
   IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
   IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
   
   
-  The /speckit.checklist command MUST replace these with actual items based on:
+  The /speckit-checklist command MUST replace these with actual items based on:
   - User's specific checklist request
   - User's specific checklist request
   - Feature requirements from spec.md
   - Feature requirements from spec.md
   - Technical context from plan.md
   - Technical context from plan.md

+ 23 - 14
.specify/templates/plan-template.md

@@ -1,9 +1,10 @@
 # Implementation Plan: [FEATURE]
 # Implementation Plan: [FEATURE]
 
 
 **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
 **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
+
 **Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
 **Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
 
 
-**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
+**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
 
 
 ## Summary
 ## Summary
 
 
@@ -17,14 +18,22 @@
   the iteration process.
   the iteration process.
 -->
 -->
 
 
-**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]  
-**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]  
-**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]  
-**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]  
+**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
+
+**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
+
+**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
+
+**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
+
 **Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
 **Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
-**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]  
-**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]  
-**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]  
+
+**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
+
+**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
+
+**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
+
 **Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
 **Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
 
 
 ## Constitution Check
 ## Constitution Check
@@ -39,12 +48,12 @@
 
 
 ```text
 ```text
 specs/[###-feature]/
 specs/[###-feature]/
-├── plan.md              # This file (/speckit.plan command output)
-├── research.md          # Phase 0 output (/speckit.plan command)
-├── data-model.md        # Phase 1 output (/speckit.plan command)
-├── quickstart.md        # Phase 1 output (/speckit.plan command)
-├── contracts/           # Phase 1 output (/speckit.plan command)
-└── tasks.md             # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
+├── plan.md              # This file (/speckit-plan command output)
+├── research.md          # Phase 0 output (/speckit-plan command)
+├── data-model.md        # Phase 1 output (/speckit-plan command)
+├── quickstart.md        # Phase 1 output (/speckit-plan command)
+├── contracts/           # Phase 1 output (/speckit-plan command)
+└── tasks.md             # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
 ```
 ```
 
 
 ### Source Code (repository root)
 ### Source Code (repository root)

+ 8 - 5
.specify/templates/spec-template.md

@@ -1,8 +1,11 @@
 # Feature Specification: [FEATURE NAME]
 # Feature Specification: [FEATURE NAME]
 
 
-**Feature Branch**: `[###-feature-name]`  
-**Created**: [DATE]  
-**Status**: Draft  
+**Feature Branch**: `[###-feature-name]`
+
+**Created**: [DATE]
+
+**Status**: Draft
+
 **Input**: User description: "$ARGUMENTS"
 **Input**: User description: "$ARGUMENTS"
 
 
 ## User Scenarios & Testing *(mandatory)*
 ## User Scenarios & Testing *(mandatory)*
@@ -11,7 +14,7 @@
   IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
   IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
   Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
   Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
   you should still have a viable MVP (Minimum Viable Product) that delivers value.
   you should still have a viable MVP (Minimum Viable Product) that delivers value.
-  
+
   Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
   Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
   Think of each story as a standalone slice of functionality that can be:
   Think of each story as a standalone slice of functionality that can be:
   - Developed independently
   - Developed independently
@@ -85,7 +88,7 @@
 ### Functional Requirements
 ### Functional Requirements
 
 
 - **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
 - **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
-- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]  
+- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
 - **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
 - **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
 - **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
 - **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
 - **FR-005**: System MUST [behavior, e.g., "log all security events"]
 - **FR-005**: System MUST [behavior, e.g., "log all security events"]

+ 6 - 5
.specify/templates/tasks-template.md

@@ -6,6 +6,7 @@ description: "Task list template for feature implementation"
 # Tasks: [FEATURE NAME]
 # Tasks: [FEATURE NAME]
 
 
 **Input**: Design documents from `/specs/[###-feature-name]/`
 **Input**: Design documents from `/specs/[###-feature-name]/`
+
 **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
 **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
 
 
 **Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
 **Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
@@ -25,21 +26,21 @@ description: "Task list template for feature implementation"
 - **Mobile**: `api/src/`, `ios/src/` or `android/src/`
 - **Mobile**: `api/src/`, `ios/src/` or `android/src/`
 - Paths shown below assume single project - adjust based on plan.md structure
 - Paths shown below assume single project - adjust based on plan.md structure
 
 
-<!-- 
+<!--
   ============================================================================
   ============================================================================
   IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
   IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
-  
-  The /speckit.tasks command MUST replace these with actual tasks based on:
+
+  The /speckit-tasks command MUST replace these with actual tasks based on:
   - User stories from spec.md (with their priorities P1, P2, P3...)
   - User stories from spec.md (with their priorities P1, P2, P3...)
   - Feature requirements from plan.md
   - Feature requirements from plan.md
   - Entities from data-model.md
   - Entities from data-model.md
   - Endpoints from contracts/
   - Endpoints from contracts/
-  
+
   Tasks MUST be organized by user story so each story can be:
   Tasks MUST be organized by user story so each story can be:
   - Implemented independently
   - Implemented independently
   - Tested independently
   - Tested independently
   - Delivered as an MVP increment
   - Delivered as an MVP increment
-  
+
   DO NOT keep these sample tasks in the generated tasks.md file.
   DO NOT keep these sample tasks in the generated tasks.md file.
   ============================================================================
   ============================================================================
 -->
 -->

+ 77 - 0
.specify/workflows/speckit/workflow.yml

@@ -0,0 +1,77 @@
+schema_version: "1.0"
+workflow:
+  id: "speckit"
+  name: "Full SDD Cycle"
+  version: "1.0.0"
+  author: "GitHub"
+  description: "Runs specify → plan → tasks → implement with review gates"
+
+requires:
+  # 0.8.5 is the first release with engine-side resolution of the
+  # ``integration: "auto"`` default. Older versions would treat "auto"
+  # as a literal integration key and fail at dispatch.
+  speckit_version: ">=0.8.5"
+  integrations:
+    # The four commands below (specify, plan, tasks, implement) are core
+    # spec-kit commands provided by every integration. The list here is an
+    # advisory, non-exhaustive compatibility hint following the documented
+    # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
+    # against any integration the project was initialized with, including
+    # ones not listed below, as long as that integration provides the four
+    # core commands referenced in ``steps``.
+    any:
+      - "claude"
+      - "copilot"
+      - "gemini"
+      - "opencode"
+
+inputs:
+  spec:
+    type: string
+    required: true
+    prompt: "Describe what you want to build"
+  integration:
+    type: string
+    default: "auto"
+    prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
+  scope:
+    type: string
+    default: "full"
+    enum: ["full", "backend-only", "frontend-only"]
+
+steps:
+  - id: specify
+    command: speckit.specify
+    integration: "{{ inputs.integration }}"
+    input:
+      args: "{{ inputs.spec }}"
+
+  - id: review-spec
+    type: gate
+    message: "Review the generated spec before planning."
+    options: [approve, reject]
+    on_reject: abort
+
+  - id: plan
+    command: speckit.plan
+    integration: "{{ inputs.integration }}"
+    input:
+      args: "{{ inputs.spec }}"
+
+  - id: review-plan
+    type: gate
+    message: "Review the plan before generating tasks."
+    options: [approve, reject]
+    on_reject: abort
+
+  - id: tasks
+    command: speckit.tasks
+    integration: "{{ inputs.integration }}"
+    input:
+      args: "{{ inputs.spec }}"
+
+  - id: implement
+    command: speckit.implement
+    integration: "{{ inputs.integration }}"
+    input:
+      args: "{{ inputs.spec }}"

+ 13 - 0
.specify/workflows/workflow-registry.json

@@ -0,0 +1,13 @@
+{
+  "schema_version": "1.0",
+  "workflows": {
+    "speckit": {
+      "name": "Full SDD Cycle",
+      "version": "1.0.0",
+      "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
+      "source": "bundled",
+      "installed_at": "2026-06-01T02:01:41.119266+00:00",
+      "updated_at": "2026-06-01T02:01:41.119266+00:00"
+    }
+  }
+}

+ 6 - 1
CLAUDE.md

@@ -1,4 +1,4 @@
-# foodie_server Development Guidelines
+# foodie_server Development Guidelines
 
 
 Auto-generated from all feature plans. Last updated: 2026-05-15
 Auto-generated from all feature plans. Last updated: 2026-05-15
 
 
@@ -149,3 +149,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 ---
 ---
 
 
 **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
 **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
+
+<!-- SPECKIT START -->
+For additional context about technologies to be used, project structure,
+shell commands, and other important information, read the current plan
+<!-- SPECKIT END -->

+ 118 - 0
docs/research-meituan-discount-product.md

@@ -0,0 +1,118 @@
+# 美团商家端「折扣商品」促销活动调研
+
+> 调研日期:2026-06-02
+> 目的:对比我方「折扣区分组」模式与美团等主流平台的折扣商品设置方式
+
+---
+
+## 一、美团折扣商品设置方式
+
+### 操作路径
+```
+美团商家版 → 营销中心 → 活动中心 → 折扣商品活动 → 立即创建
+```
+
+### 核心机制:**每店一个折扣活动,逐商品独立设折扣**
+
+美团的做法是:
+1. **每个店铺只能创建一个「折扣商品」活动**,不能同时创建多个
+2. 活动名称是通用标题(如"超值特惠""招牌甄选""精选折扣"),不涉及具体折扣率
+3. 所有想打折的商品都放在这**唯一一个活动**下面
+4. **每个商品单独设置折扣力度**(如酸菜鱼9折、猪排饭8折、奶茶7折)
+5. 每个商品可单独设置优惠份数、限购数量
+6. **同一个商品不能同时参加多个活动**(商品级互斥)
+
+**不存在「折扣区」分组概念。** 一个活动内所有商品平铺展示,每个商品各有自己的折扣率。
+
+```
+一个店铺的折扣结构(美团):
+
+折扣商品活动(唯一,名称如"超值特惠")
+├── 酸菜鱼 9折
+├── 猪排饭 8折
+├── 奶茶   7折
+└── 蛋糕   6折
+```
+
+### 活动互斥规则
+| 活动 | 与折扣商品的关系 |
+|------|----------------|
+| 满减活动 | **互斥**(不可同时享受) |
+| 首单立减 | **互斥** |
+| 第二份半价 | **互斥** |
+| 店外发券 | **互斥** |
+| 新客立减 | 可同享 |
+| 爆品活动 | 可同享 |
+
+### 美团的建议策略
+来自 [美团外卖店铺活动创建攻略](https://zhuanlan.zhihu.com/p/592315103):
+- **首选高毛利商品**做折扣,保证促销时仍有利可赚
+- **折扣呈梯度展示**,如4折、6折、7折,给用户更多选择
+- **不建议全店商品都设折扣**,会被系统判定为虚假优惠
+- **不建议满减+全折扣套路**,顾客进店发现不能同时享受会产生被欺骗感
+
+---
+
+## 二、饿了么对比
+
+饿了么商家端做法与美团类似:
+1. 创建「商品折扣」活动
+2. 批量选择商品
+3. 可按分类批量设折扣率,也可**逐个商品设置不同折扣力度**
+4. 同样是逐商品设置,无分组概念
+
+---
+
+## 三、与我方当前实现的对比
+
+| 维度 | 我方(折扣区分组) | 美团/饿了么(逐商品设置) |
+|------|-------------------|------------------------|
+| **组织方式** | 按折扣率分组,同组商品共享折扣率 | 商品列表扁平化,每个商品独立设折扣 |
+| **数据结构** | 活动 → 折扣区(折扣率) → 商品列表 | 活动 → 商品列表(每个商品带折扣率) |
+| **操作步骤** | 建折扣区 → 设折扣率 → 添加商品到区 | 选商品 → 逐个/批量设折扣 |
+| **灵活性** | 同区商品必须同折扣率 | 每个商品可独立折扣 |
+| **商家认知** | 需理解「折扣区」中间层 | 直觉:选中商品 → 打几折 |
+| **UI复杂度** | 需要管理区的增删 | 商品表格,每行一个折扣设置 |
+
+---
+
+## 四、结论与建议
+
+### 问题
+我方当前「按折扣率分组」的方式:
+1. **多一层抽象**:商家需要先理解「折扣区」概念,再往里添加商品
+2. **灵活性差**:同一折扣区商品必须同一折扣率,无法单个调整
+3. **不符合行业惯例**:美团、饿了么都是逐商品设置
+
+### 建议:改为逐商品设置折扣
+
+**UI 改为:**
+```
+活动名称: [折扣促销]
+活动时间: [开始] ~ [结束]
+
+商品列表:
+┌──────────────┬────────┬──────────────┬──────────┐
+│ 商品名称      │ 原价    │ 折扣率        │ 折后价    │
+├──────────────┼────────┼──────────────┼──────────┤
+│ 酸菜巴沙鱼    │ 199    │ 90% [-][+]   │ 179.1    │
+│ 酸菜豬肉片    │ 211    │ 80% [-][+]   │ 168.8    │
+│ 奶茶          │ 50     │ 70% [-][+]   │ 35       │
+└──────────────┴────────┴──────────────┴──────────┘
+
+[+ 添加商品]
+```
+
+**数据结构调整:**
+- 去掉 `PromotionActivityRule`(折扣区)这层中间表
+- 在商品关联表上直接记录折扣率
+- 一个活动 = 一批商品,每个商品各有折扣率
+
+---
+
+## 参考来源
+
+- [美团外卖店铺活动创建攻略 - 知乎](https://zhuanlan.zhihu.com/p/592315103)
+- [美团外卖降低商家营销流量权重 - 美团官方](https://www.meituan.com/news/NN250611087003453)
+- [美团外卖平台市场营销规则](https://rules-center.meituan.com/rules-detail/788?commonType=7)
+- [新版商家中心操作指引 - 美团外卖课堂](https://collegewm.meituan.com/sg/post/detail?id=206&contentType=0)

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java

@@ -88,4 +88,27 @@ public class ShPromotionActivityController extends BaseController
     {
     {
         return toAjax(promotionActivityService.endActivity(id));
         return toAjax(promotionActivityService.endActivity(id));
     }
     }
+
+    /**
+     * 修改促销活动(仅未开始)
+     */
+    @Anonymous
+    @Auth
+    @PutMapping
+    public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionActivity activity)
+    {
+        List<PromotionActivityRule> rules = activity.getRules();
+        return toAjax(promotionActivityService.updateActivity(activity, rules));
+    }
+
+    /**
+     * 删除促销活动(仅未开始)
+     */
+    @Anonymous
+    @Auth
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@RequestHeader String token, @PathVariable("id") Long id)
+    {
+        return toAjax(promotionActivityService.deleteActivity(id));
+    }
 }
 }

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java

@@ -12,6 +12,8 @@ import com.ruoyi.system.service.IPromotionCouponBatchService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.Auth;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
+import java.util.ArrayList;
+import java.util.List;
 
 
 /**
 /**
  * 商家端优惠券管理
  * 商家端优惠券管理
@@ -72,6 +74,11 @@ public class ShPromotionCouponController extends BaseController
     @PostMapping
     @PostMapping
     public AjaxResult add(@RequestHeader String token, @RequestBody PromotionCouponBatch batch)
     public AjaxResult add(@RequestHeader String token, @RequestBody PromotionCouponBatch batch)
     {
     {
+        List<PromotionCouponRule> rules = batch.getRules();
+        if (rules != null && !rules.isEmpty())
+        {
+            return toAjax(promotionCouponBatchService.createBatch(batch, rules));
+        }
         PromotionCouponRule rule = batch.getRule();
         PromotionCouponRule rule = batch.getRule();
         return toAjax(promotionCouponBatchService.createBatch(batch, rule));
         return toAjax(promotionCouponBatchService.createBatch(batch, rule));
     }
     }
@@ -86,4 +93,32 @@ public class ShPromotionCouponController extends BaseController
     {
     {
         return toAjax(promotionCouponBatchService.offShelfBatch(id));
         return toAjax(promotionCouponBatchService.offShelfBatch(id));
     }
     }
+
+    /**
+     * 修改优惠券
+     */
+    @Anonymous
+    @Auth
+    @PutMapping
+    public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionCouponBatch batch)
+    {
+        List<PromotionCouponRule> rules = batch.getRules();
+        if (rules != null && !rules.isEmpty())
+        {
+            return toAjax(promotionCouponBatchService.updateBatch(batch, rules));
+        }
+        PromotionCouponRule rule = batch.getRule();
+        return toAjax(promotionCouponBatchService.updateBatch(batch, rule));
+    }
+
+    /**
+     * 删除优惠券(仅未开始且无人领取)
+     */
+    @Anonymous
+    @Auth
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@RequestHeader String token, @PathVariable("id") Long id)
+    {
+        return toAjax(promotionCouponBatchService.deleteBatch(id));
+    }
 }
 }

+ 0 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPointController.java

@@ -16,7 +16,6 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.JwtUtil;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiOperation;
-import net.sf.jsqlparser.expression.LongValue;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.RequestHeader;
 import org.springframework.web.bind.annotation.RequestHeader;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMapping;

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.user;
+
+import java.util.List;
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.service.IPromotionCalcService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+
+/**
+ * 用户端优惠计算接口
+ */
+@RestController
+@RequestMapping("/app/userPromotionCalc")
+public class UserPromotionCalcController extends BaseController {
+
+    @Autowired
+    private IPromotionCalcService promotionCalcService;
+
+    /**
+     * 计算订单优惠
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/calculate")
+    public AjaxResult calculate(@RequestHeader String token,@RequestBody Map<String, Object> params) {
+        JwtUtil jwtUtil=new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        if (userId == null) {
+            return AjaxResult.error("请先登录");
+        }
+        if (params.get("storeId") == null) {
+            return AjaxResult.error("参数错误:缺少storeId");
+        }
+        if (params.get("items") == null) {
+            return AjaxResult.error("参数错误:缺少items");
+        }
+
+        Long storeId = Long.valueOf(params.get("storeId").toString());
+        List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
+        Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
+        String forcePath = params.get("forcePath") != null ? params.get("forcePath").toString() : null;
+
+        Map<String, Object> result = promotionCalcService.calculate(storeId, items, Long.valueOf(userId), couponId, forcePath);
+        return AjaxResult.success(result);
+    }
+}

+ 78 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCouponController.java

@@ -0,0 +1,78 @@
+package com.ruoyi.app.user;
+
+import java.util.List;
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+import com.ruoyi.system.service.IPromotionUserCouponService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+
+/**
+ * 用户端优惠券接口
+ */
+@RestController
+@RequestMapping("/app/userPromotionCoupon")
+public class UserPromotionCouponController extends BaseController {
+
+    @Autowired
+    private IPromotionUserCouponService userCouponService;
+
+    /**
+     * 查询门店可领优惠券列表
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/storeCoupons")
+    public AjaxResult storeCoupons(@RequestHeader String token,@RequestParam Long storeId) {
+        JwtUtil jwtUtil=new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        if (userId == null) {
+            return AjaxResult.error("请先登录");
+        }
+        List<Map<String, Object>> list = userCouponService.selectStoreCouponsForUser(Long.valueOf(userId), storeId);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 领取优惠券
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/receive")
+    public AjaxResult receive(@RequestHeader String token,@RequestBody Map<String, Object> params) {
+        JwtUtil jwtUtil=new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        if (userId == null) {
+            return AjaxResult.error("请先登录");
+        }
+        if (params.get("batchId") == null) {
+            return AjaxResult.error("参数错误:缺少batchId");
+        }
+        Long batchId = Long.valueOf(params.get("batchId").toString());
+        Long storeId = params.get("storeId") != null ? Long.valueOf(params.get("storeId").toString()) : null;
+        PromotionUserCoupon coupon = userCouponService.receiveCoupon(Long.valueOf(userId), batchId, storeId);
+        return AjaxResult.success("领取成功", coupon);
+    }
+
+    /**
+     * 查询我的优惠券列表
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/myCoupons")
+    public AjaxResult myCoupons(@RequestHeader String token,@RequestParam(required = false) Integer status,
+                                @RequestParam(required = false) Long storeId) {
+        JwtUtil jwtUtil=new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        if (userId == null) {
+            return AjaxResult.error("请先登录");
+        }
+        List<Map<String, Object>> list = userCouponService.selectMyCoupons(Long.valueOf(userId), status, storeId);
+        return AjaxResult.success(list);
+    }
+}

+ 192 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderPromotion.java

@@ -0,0 +1,192 @@
+package com.ruoyi.system.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.GeneratedValue;
+
+/**
+ * PosOrderPromotion对象 pos_order_promotion
+ *
+ * @author ruoyi
+ * @date 2024-05-30
+ */
+@Data
+@TableName(value = "pos_order_promotion")
+@EqualsAndHashCode(callSuper = false)
+public class PosOrderPromotion
+{
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    @GeneratedValue
+    /** id */
+    private Long id;
+
+    /** 订单ID */
+    private Long orderId;
+
+    /** 促销类型: 1=活动促销 2=优惠券 */
+    private Integer promoType;
+
+    /** 促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 */
+    private Integer promoSubType;
+
+    /** 促销活动ID */
+    private Long promoId;
+
+    /** 用户优惠券ID */
+    private Long userCouponId;
+
+    /** 促销名称 */
+    private String promoName;
+
+    /** 促销详情 */
+    private String promoDetail;
+
+    /** 优惠金额 */
+    private BigDecimal reduceAmount;
+
+    /** 命中规则摘要 */
+    private String pathSummary;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    public void setId(Long id)
+    {
+        this.id = id;
+    }
+
+    public Long getId()
+    {
+        return id;
+    }
+
+    public void setOrderId(Long orderId)
+    {
+        this.orderId = orderId;
+    }
+
+    public Long getOrderId()
+    {
+        return orderId;
+    }
+
+    public void setPromoType(Integer promoType)
+    {
+        this.promoType = promoType;
+    }
+
+    public Integer getPromoType()
+    {
+        return promoType;
+    }
+
+    public void setPromoSubType(Integer promoSubType)
+    {
+        this.promoSubType = promoSubType;
+    }
+
+    public Integer getPromoSubType()
+    {
+        return promoSubType;
+    }
+
+    public void setPromoId(Long promoId)
+    {
+        this.promoId = promoId;
+    }
+
+    public Long getPromoId()
+    {
+        return promoId;
+    }
+
+    public void setUserCouponId(Long userCouponId)
+    {
+        this.userCouponId = userCouponId;
+    }
+
+    public Long getUserCouponId()
+    {
+        return userCouponId;
+    }
+
+    public void setPromoName(String promoName)
+    {
+        this.promoName = promoName;
+    }
+
+    public String getPromoName()
+    {
+        return promoName;
+    }
+
+    public void setPromoDetail(String promoDetail)
+    {
+        this.promoDetail = promoDetail;
+    }
+
+    public String getPromoDetail()
+    {
+        return promoDetail;
+    }
+
+    public void setReduceAmount(BigDecimal reduceAmount)
+    {
+        this.reduceAmount = reduceAmount;
+    }
+
+    public BigDecimal getReduceAmount()
+    {
+        return reduceAmount;
+    }
+
+    public void setPathSummary(String pathSummary)
+    {
+        this.pathSummary = pathSummary;
+    }
+
+    public String getPathSummary()
+    {
+        return pathSummary;
+    }
+
+    public void setCreateTime(Date createTime)
+    {
+        this.createTime = createTime;
+    }
+
+    public Date getCreateTime()
+    {
+        return createTime;
+    }
+
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
+            .append("id", getId())
+            .append("orderId", getOrderId())
+            .append("promoType", getPromoType())
+            .append("promoSubType", getPromoSubType())
+            .append("promoId", getPromoId())
+            .append("userCouponId", getUserCouponId())
+            .append("promoName", getPromoName())
+            .append("promoDetail", getPromoDetail())
+            .append("reduceAmount", getReduceAmount())
+            .append("pathSummary", getPathSummary())
+            .append("createTime", getCreateTime())
+            .toString();
+    }
+}

+ 3 - 13
ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivity.java

@@ -11,12 +11,10 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import javax.persistence.GeneratedValue;
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 
 /**
 /**
  * PromotionActivity对象 promotion_activity
  * PromotionActivity对象 promotion_activity
@@ -37,39 +35,31 @@ public class PromotionActivity
     private Long id;
     private Long id;
 
 
     /** 门店ID */
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
     private Long storeId;
 
 
     /** 类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 */
     /** 类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 */
-    @Excel(name = "类型")
     private Integer type;
     private Integer type;
 
 
     /** 活动名称 */
     /** 活动名称 */
-    @Excel(name = "活动名称")
     private String name;
     private String name;
 
 
     /** 0=未开始 1=进行中 2=已结束 */
     /** 0=未开始 1=进行中 2=已结束 */
-    @Excel(name = "状态")
     private Integer status;
     private Integer status;
 
 
     /** 开始时间 */
     /** 开始时间 */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private Date startTime;
     private Date startTime;
 
 
     /** 结束时间 */
     /** 结束时间 */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private Date endTime;
     private Date endTime;
 
 
     /** 创建时间 */
     /** 创建时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
     private Date createTime;
 
 
     /** 更新时间 */
     /** 更新时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
     private Date updateTime;
 
 
     /** 活动规则 */
     /** 活动规则 */
@@ -184,4 +174,4 @@ public class PromotionActivity
             .append("updateTime", getUpdateTime())
             .append("updateTime", getUpdateTime())
             .toString();
             .toString();
     }
     }
-}
+}

+ 1 - 9
ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivityRule.java

@@ -10,12 +10,10 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import javax.persistence.GeneratedValue;
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 
 /**
 /**
  * PromotionActivityRule对象 promotion_activity_rule
  * PromotionActivityRule对象 promotion_activity_rule
@@ -36,27 +34,21 @@ public class PromotionActivityRule
     private Long id;
     private Long id;
 
 
     /** 关联促销活动 */
     /** 关联促销活动 */
-    @Excel(name = "关联促销活动")
     private Long activityId;
     private Long activityId;
 
 
     /** 商品ID */
     /** 商品ID */
-    @Excel(name = "商品ID")
     private Long productId;
     private Long productId;
 
 
     /** 满减门槛 */
     /** 满减门槛 */
-    @Excel(name = "满减门槛")
     private BigDecimal threshold;
     private BigDecimal threshold;
 
 
     /** 减免金额 */
     /** 减免金额 */
-    @Excel(name = "减免金额")
     private BigDecimal reduceAmount;
     private BigDecimal reduceAmount;
 
 
     /** 折扣率 */
     /** 折扣率 */
-    @Excel(name = "折扣率")
     private BigDecimal discountRate;
     private BigDecimal discountRate;
 
 
     /** 最低数量 */
     /** 最低数量 */
-    @Excel(name = "最低数量")
     private Integer minQuantity;
     private Integer minQuantity;
 
 
     /** 商品名称 */
     /** 商品名称 */
@@ -153,4 +145,4 @@ public class PromotionActivityRule
             .append("minQuantity", getMinQuantity())
             .append("minQuantity", getMinQuantity())
             .toString();
             .toString();
     }
     }
-}
+}

+ 10 - 19
ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponBatch.java

@@ -10,12 +10,11 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import javax.persistence.GeneratedValue;
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
+import java.util.List;
 
 
 /**
 /**
  * PromotionCouponBatch对象 promotion_coupon_batch
  * PromotionCouponBatch对象 promotion_coupon_batch
@@ -36,61 +35,53 @@ public class PromotionCouponBatch
     private Long id;
     private Long id;
 
 
     /** 门店ID */
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
     private Long storeId;
 
 
     /** 券名称 */
     /** 券名称 */
-    @Excel(name = "券名称")
     private String name;
     private String name;
 
 
-    /** 1=满减券 2=商品券 */
-    @Excel(name = "券类型")
+    /** 1=满减券 2=商品券 3=免配送费券 */
     private Integer couponType;
     private Integer couponType;
 
 
     /** 发放总量 */
     /** 发放总量 */
-    @Excel(name = "发放总量")
     private Integer totalCount;
     private Integer totalCount;
 
 
     /** 剩余数量 */
     /** 剩余数量 */
-    @Excel(name = "剩余数量")
     private Integer remainCount;
     private Integer remainCount;
 
 
     /** 已领取数量 */
     /** 已领取数量 */
-    @Excel(name = "已领取数量")
     private Integer receivedCount;
     private Integer receivedCount;
 
 
     /** 0=未开始 1=进行中 2=已结束 3=已下架 */
     /** 0=未开始 1=进行中 2=已结束 3=已下架 */
-    @Excel(name = "状态")
     private Integer status;
     private Integer status;
 
 
     /** 开始时间 */
     /** 开始时间 */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private Date startTime;
     private Date startTime;
 
 
     /** 结束时间 */
     /** 结束时间 */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private Date endTime;
     private Date endTime;
 
 
     /** 有效天数 */
     /** 有效天数 */
-    @Excel(name = "有效天数")
     private Integer validDays;
     private Integer validDays;
 
 
     /** 创建时间 */
     /** 创建时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
     private Date createTime;
 
 
     /** 更新时间 */
     /** 更新时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
     private Date updateTime;
 
 
-    /** 券规则 */
+    /** 券规则(单条,兼容旧接口) */
     @TableField(exist = false)
     @TableField(exist = false)
     private PromotionCouponRule rule;
     private PromotionCouponRule rule;
 
 
+    /** 券规则列表(多商品券) */
+    @TableField(exist = false)
+    private List<PromotionCouponRule> rules;
+
     /** 是否可编辑 */
     /** 是否可编辑 */
     @TableField(exist = false)
     @TableField(exist = false)
     private boolean editable;
     private boolean editable;
@@ -243,4 +234,4 @@ public class PromotionCouponBatch
             .append("updateTime", getUpdateTime())
             .append("updateTime", getUpdateTime())
             .toString();
             .toString();
     }
     }
-}
+}

+ 1 - 9
ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponRule.java

@@ -10,12 +10,10 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import javax.persistence.GeneratedValue;
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 
 /**
 /**
  * PromotionCouponRule对象 promotion_coupon_rule
  * PromotionCouponRule对象 promotion_coupon_rule
@@ -36,27 +34,21 @@ public class PromotionCouponRule
     private Long id;
     private Long id;
 
 
     /** 批次ID */
     /** 批次ID */
-    @Excel(name = "批次ID")
     private Long batchId;
     private Long batchId;
 
 
     /** 商品ID */
     /** 商品ID */
-    @Excel(name = "商品ID")
     private Long productId;
     private Long productId;
 
 
     /** 0=同享 1=互斥 */
     /** 0=同享 1=互斥 */
-    @Excel(name = "互斥状态")
     private Integer isMutex;
     private Integer isMutex;
 
 
     /** 门槛金额 */
     /** 门槛金额 */
-    @Excel(name = "门槛金额")
     private BigDecimal threshold;
     private BigDecimal threshold;
 
 
     /** 券面额 */
     /** 券面额 */
-    @Excel(name = "券面额")
     private BigDecimal amount;
     private BigDecimal amount;
 
 
     /** 折扣率 */
     /** 折扣率 */
-    @Excel(name = "折扣率")
     private BigDecimal discountRate;
     private BigDecimal discountRate;
 
 
     /** 商品名称 */
     /** 商品名称 */
@@ -153,4 +145,4 @@ public class PromotionCouponRule
             .append("discountRate", getDiscountRate())
             .append("discountRate", getDiscountRate())
             .toString();
             .toString();
     }
     }
-}
+}

+ 9 - 11
ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionUserCoupon.java

@@ -10,12 +10,10 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import javax.persistence.GeneratedValue;
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 
 /**
 /**
  * PromotionUserCoupon对象 promotion_user_coupon
  * PromotionUserCoupon对象 promotion_user_coupon
@@ -36,40 +34,40 @@ public class PromotionUserCoupon
     private Long id;
     private Long id;
 
 
     /** 用户ID */
     /** 用户ID */
-    @Excel(name = "用户ID")
     private Long userId;
     private Long userId;
 
 
     /** 批次ID */
     /** 批次ID */
-    @Excel(name = "批次ID")
     private Long batchId;
     private Long batchId;
 
 
     /** 门店ID */
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
     private Long storeId;
 
 
     /** 0=未使用 1=已使用 2=已过期 3=冻结 */
     /** 0=未使用 1=已使用 2=已过期 3=冻结 */
-    @Excel(name = "状态")
     private Integer status;
     private Integer status;
 
 
     /** 订单ID */
     /** 订单ID */
-    @Excel(name = "订单ID")
     private Long orderId;
     private Long orderId;
 
 
     /** 领取时间 */
     /** 领取时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "领取时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date receiveTime;
     private Date receiveTime;
 
 
     /** 使用时间 */
     /** 使用时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "使用时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date useTime;
     private Date useTime;
 
 
     /** 过期时间 */
     /** 过期时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "过期时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date expireTime;
     private Date expireTime;
 
 
+    /** 优惠券名称 */
+    @TableField(exist = false)
+    private String couponName;
+
+    /** 门店名称 */
+    @TableField(exist = false)
+    private String storeName;
+
     public void setId(Long id)
     public void setId(Long id)
     {
     {
         this.id = id;
         this.id = id;
@@ -174,4 +172,4 @@ public class PromotionUserCoupon
             .append("expireTime", getExpireTime())
             .append("expireTime", getExpireTime())
             .toString();
             .toString();
     }
     }
-}
+}

+ 29 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderPromotionMapper.java

@@ -0,0 +1,29 @@
+package com.ruoyi.system.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderPromotion;
+
+/**
+ * 订单促销记录Mapper接口
+ *
+ * @author ruoyi
+ */
+public interface PosOrderPromotionMapper extends BaseMapper<PosOrderPromotion> {
+
+    /**
+     * 根据订单ID查询促销记录
+     *
+     * @param orderId 订单ID
+     * @return 促销记录列表
+     */
+    public List<PosOrderPromotion> selectByOrderId(Long orderId);
+
+    /**
+     * 批量新增订单促销记录
+     *
+     * @param list 订单促销记录列表
+     * @return 影响行数
+     */
+    public int insertBatch(List<PosOrderPromotion> list);
+}

+ 43 - 4
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java

@@ -1,14 +1,53 @@
 package com.ruoyi.system.mapper;
 package com.ruoyi.system.mapper;
 
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionActivity;
 import com.ruoyi.system.domain.PromotionActivity;
 
 
 /**
 /**
- * PromotionActivityMapper接口
+ * 促销活动Mapper接口
  *
  *
  * @author ruoyi
  * @author ruoyi
- * @date 2024-05-30
  */
  */
-public interface PromotionActivityMapper extends BaseMapper<PromotionActivity>
-{
+public interface PromotionActivityMapper extends BaseMapper<PromotionActivity> {
+
+    /**
+     * 查询促销活动列表
+     *
+     * @param promotionActivity 查询条件
+     * @return 促销活动列表
+     */
+    public List<PromotionActivity> selectPromotionActivityList(PromotionActivity promotionActivity);
+
+    /**
+     * 根据ID查询促销活动
+     *
+     * @param id 促销活动ID
+     * @return 促销活动
+     */
+    public PromotionActivity selectPromotionActivityById(Long id);
+
+    /**
+     * 新增促销活动
+     *
+     * @param promotionActivity 促销活动
+     * @return 影响行数
+     */
+    public int insertPromotionActivity(PromotionActivity promotionActivity);
+
+    /**
+     * 修改促销活动
+     *
+     * @param promotionActivity 促销活动
+     * @return 影响行数
+     */
+    public int updatePromotionActivity(PromotionActivity promotionActivity);
+
+    /**
+     * 根据门店ID查询生效的促销活动
+     *
+     * @param storeId 门店ID
+     * @return 促销活动列表
+     */
+    public List<PromotionActivity> selectActiveByStoreId(Long storeId);
 }
 }

+ 28 - 5
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityRuleMapper.java

@@ -5,18 +5,41 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionActivityRule;
 import com.ruoyi.system.domain.PromotionActivityRule;
 
 
 /**
 /**
- * PromotionActivityRuleMapper接口
+ * 促销活动规则Mapper接口
  *
  *
  * @author ruoyi
  * @author ruoyi
- * @date 2024-05-30
  */
  */
-public interface PromotionActivityRuleMapper extends BaseMapper<PromotionActivityRule>
-{
+public interface PromotionActivityRuleMapper extends BaseMapper<PromotionActivityRule> {
+
     /**
     /**
      * 根据活动ID查询规则列表
      * 根据活动ID查询规则列表
      *
      *
      * @param activityId 活动ID
      * @param activityId 活动ID
      * @return 规则列表
      * @return 规则列表
      */
      */
-    List<PromotionActivityRule> selectRulesByActivityId(Long activityId);
+    public List<PromotionActivityRule> selectRulesByActivityId(Long activityId);
+
+    /**
+     * 批量新增活动规则
+     *
+     * @param rules 规则列表
+     * @return 影响行数
+     */
+    public int insertBatch(List<PromotionActivityRule> rules);
+
+    /**
+     * 根据活动ID删除规则
+     *
+     * @param activityId 活动ID
+     * @return 影响行数
+     */
+    public int deleteByActivityId(Long activityId);
+
+    /**
+     * 根据门店ID查询所有生效活动的规则
+     *
+     * @param storeId 门店ID
+     * @return 规则列表
+     */
+    public List<PromotionActivityRule> selectActiveRulesByStoreId(Long storeId);
 }
 }

+ 43 - 4
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponBatchMapper.java

@@ -1,14 +1,53 @@
 package com.ruoyi.system.mapper;
 package com.ruoyi.system.mapper;
 
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 
 
 /**
 /**
- * PromotionCouponBatchMapper接口
+ * 优惠券批次Mapper接口
  *
  *
  * @author ruoyi
  * @author ruoyi
- * @date 2024-05-30
  */
  */
-public interface PromotionCouponBatchMapper extends BaseMapper<PromotionCouponBatch>
-{
+public interface PromotionCouponBatchMapper extends BaseMapper<PromotionCouponBatch> {
+
+    /**
+     * 查询优惠券批次列表
+     *
+     * @param batch 查询条件
+     * @return 优惠券批次列表
+     */
+    public List<PromotionCouponBatch> selectPromotionCouponBatchList(PromotionCouponBatch batch);
+
+    /**
+     * 根据ID查询优惠券批次
+     *
+     * @param id 批次ID
+     * @return 优惠券批次
+     */
+    public PromotionCouponBatch selectPromotionCouponBatchById(Long id);
+
+    /**
+     * 新增优惠券批次
+     *
+     * @param batch 优惠券批次
+     * @return 影响行数
+     */
+    public int insertPromotionCouponBatch(PromotionCouponBatch batch);
+
+    /**
+     * 修改优惠券批次
+     *
+     * @param batch 优惠券批次
+     * @return 影响行数
+     */
+    public int updatePromotionCouponBatch(PromotionCouponBatch batch);
+
+    /**
+     * 根据门店ID查询生效且有剩余的优惠券批次
+     *
+     * @param storeId 门店ID
+     * @return 优惠券批次列表
+     */
+    public List<PromotionCouponBatch> selectActiveByStoreId(Long storeId);
 }
 }

+ 31 - 7
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponRuleMapper.java

@@ -2,20 +2,44 @@ package com.ruoyi.system.mapper;
 
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionCouponRule;
 import com.ruoyi.system.domain.PromotionCouponRule;
+import java.util.List;
 
 
 /**
 /**
- * PromotionCouponRuleMapper接口
+ * 优惠券规则Mapper接口
  *
  *
  * @author ruoyi
  * @author ruoyi
- * @date 2024-05-30
  */
  */
-public interface PromotionCouponRuleMapper extends BaseMapper<PromotionCouponRule>
-{
+public interface PromotionCouponRuleMapper extends BaseMapper<PromotionCouponRule> {
+
+    /**
+     * 根据批次ID查询优惠券规则
+     *
+     * @param batchId 批次ID
+     * @return 优惠券规则
+     */
+    public PromotionCouponRule selectRuleByBatchId(Long batchId);
+
+    /**
+     * 根据批次ID查询所有优惠券规则(多商品券)
+     *
+     * @param batchId 批次ID
+     * @return 优惠券规则列表
+     */
+    public List<PromotionCouponRule> selectRulesByBatchId(Long batchId);
+
+    /**
+     * 新增优惠券规则
+     *
+     * @param rule 优惠券规则
+     * @return 影响行数
+     */
+    public int insertPromotionCouponRule(PromotionCouponRule rule);
+
     /**
     /**
-     * 根据批次ID查询券规则
+     * 根据批次ID删除优惠券规则
      *
      *
      * @param batchId 批次ID
      * @param batchId 批次ID
-     * @return 券规则
+     * @return 影响行数
      */
      */
-    PromotionCouponRule selectRuleByBatchId(Long batchId);
+    public int deleteByBatchId(Long batchId);
 }
 }

+ 28 - 5
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionUserCouponMapper.java

@@ -1,14 +1,37 @@
 package com.ruoyi.system.mapper;
 package com.ruoyi.system.mapper;
 
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionUserCoupon;
 import com.ruoyi.system.domain.PromotionUserCoupon;
 
 
 /**
 /**
- * PromotionUserCouponMapper接口
+ * 用户优惠券Mapper接口
  *
  *
  * @author ruoyi
  * @author ruoyi
- * @date 2026-05-29
  */
  */
-public interface PromotionUserCouponMapper extends BaseMapper<PromotionUserCoupon>
-{
-}
+public interface PromotionUserCouponMapper extends BaseMapper<PromotionUserCoupon> {
+
+    /**
+     * 查询用户优惠券列表
+     *
+     * @param coupon 查询条件
+     * @return 用户优惠券列表
+     */
+    public List<PromotionUserCoupon> selectPromotionUserCouponList(PromotionUserCoupon coupon);
+
+    /**
+     * 新增用户优惠券
+     *
+     * @param coupon 用户优惠券
+     * @return 影响行数
+     */
+    public int insertPromotionUserCoupon(PromotionUserCoupon coupon);
+
+    /**
+     * 修改用户优惠券
+     *
+     * @param coupon 用户优惠券
+     * @return 影响行数
+     */
+    public int updatePromotionUserCoupon(PromotionUserCoupon coupon);
+}

+ 17 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java

@@ -30,6 +30,23 @@ public interface IPromotionActivityService extends IService<PromotionActivity>
      */
      */
     PromotionActivity selectActivityWithRules(Long id);
     PromotionActivity selectActivityWithRules(Long id);
 
 
+    /**
+     * 修改促销活动(仅未开始)
+     *
+     * @param activity 活动主体
+     * @param rules 活动规则列表
+     * @return 是否成功
+     */
+    boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules);
+
+    /**
+     * 删除促销活动(仅未开始)
+     *
+     * @param id 活动ID
+     * @return 是否成功
+     */
+    boolean deleteActivity(Long id);
+
     /**
     /**
      * 结束活动
      * 结束活动
      *
      *

+ 21 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java

@@ -0,0 +1,21 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 促销算价Service接口
+ */
+public interface IPromotionCalcService {
+
+    /**
+     * 计算订单优惠
+     * @param storeId 门店ID
+     * @param items 商品列表 [{productId, quantity}]
+     * @param userId 用户ID(用于新客判断)
+     * @param couponId 可选,用户券ID
+     * @param forcePath 可选,强制路径 "A"或"B"
+     * @return 算价结果
+     */
+    Map<String, Object> calculate(Long storeId, List<Map<String, Object>> items, Long userId, Long couponId, String forcePath);
+}

+ 28 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponBatchService.java

@@ -3,6 +3,7 @@ package com.ruoyi.system.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponRule;
 import com.ruoyi.system.domain.PromotionCouponRule;
+import java.util.List;
 
 
 /**
 /**
  * 优惠券批次Service接口
  * 优惠券批次Service接口
@@ -21,6 +22,11 @@ public interface IPromotionCouponBatchService extends IService<PromotionCouponBa
      */
      */
     boolean createBatch(PromotionCouponBatch batch, PromotionCouponRule rule);
     boolean createBatch(PromotionCouponBatch batch, PromotionCouponRule rule);
 
 
+    /**
+     * 创建优惠券批次(含多条规则,多商品券)
+     */
+    boolean createBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules);
+
     /**
     /**
      * 查询批次详情(含规则)
      * 查询批次详情(含规则)
      *
      *
@@ -36,4 +42,26 @@ public interface IPromotionCouponBatchService extends IService<PromotionCouponBa
      * @return 是否成功
      * @return 是否成功
      */
      */
     boolean offShelfBatch(Long id);
     boolean offShelfBatch(Long id);
+
+    /**
+     * 修改优惠券批次(含规则)
+     *
+     * @param batch 批次主体
+     * @param rule 券规则
+     * @return 是否成功
+     */
+    boolean updateBatch(PromotionCouponBatch batch, PromotionCouponRule rule);
+
+    /**
+     * 修改优惠券批次(含多条规则,多商品券)
+     */
+    boolean updateBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules);
+
+    /**
+     * 删除优惠券批次(仅未开始且无人领取)
+     *
+     * @param id 批次ID
+     * @return 是否成功
+     */
+    boolean deleteBatch(Long id);
 }
 }

+ 27 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionUserCouponService.java

@@ -0,0 +1,27 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import java.util.Map;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+
+/**
+ * 用户优惠券Service接口
+ */
+public interface IPromotionUserCouponService extends IService<PromotionUserCoupon> {
+
+    /**
+     * 领取优惠券
+     */
+    public PromotionUserCoupon receiveCoupon(Long userId, Long batchId, Long storeId);
+
+    /**
+     * 查询门店可领优惠券列表
+     */
+    public List<Map<String, Object>> selectStoreCouponsForUser(Long userId, Long storeId);
+
+    /**
+     * 查询用户优惠券列表
+     */
+    public List<Map<String, Object>> selectMyCoupons(Long userId, Integer status, Long storeId);
+}

+ 71 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java

@@ -5,8 +5,10 @@ import java.util.List;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.mapper.PromotionActivityMapper;
 import com.ruoyi.system.mapper.PromotionActivityMapper;
 import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
 import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
 import com.ruoyi.system.domain.PromotionActivity;
 import com.ruoyi.system.domain.PromotionActivity;
@@ -47,6 +49,15 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         {
         {
             activity.setStatus(0);
             activity.setStatus(0);
         }
         }
+        // 校验:同一门店同一促销类型只允许一个进行中的活动
+        LambdaQueryWrapper<PromotionActivity> dupCheck = new LambdaQueryWrapper<>();
+        dupCheck.eq(PromotionActivity::getStoreId, activity.getStoreId())
+                .eq(PromotionActivity::getType, activity.getType())
+                .eq(PromotionActivity::getStatus, 1);
+        if (promotionActivityMapper.selectCount(dupCheck) > 0)
+        {
+            throw new ServiceException("该门店已存在进行中的同类型活动");
+        }
         int rows = promotionActivityMapper.insert(activity);
         int rows = promotionActivityMapper.insert(activity);
         Long activityId = activity.getId();
         Long activityId = activity.getId();
         // 逐条插入规则
         // 逐条插入规则
@@ -74,11 +85,70 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     }
     }
 
 
     /**
     /**
-     * 结束活动
+     * 修改促销活动(仅未开始)
+     */
+    @Override
+    @Transactional
+    public boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules)
+    {
+        Long activityId = activity.getId();
+        PromotionActivity existing = promotionActivityMapper.selectById(activityId);
+        if (existing == null || existing.getStatus() != 0)
+        {
+            throw new ServiceException("只能修改未开始的活动");
+        }
+        Date now = new Date();
+        activity.setUpdateTime(now);
+        // 根据开始时间设置状态:开始时间早于当前→进行中(1),否则→未开始(0)
+        if (activity.getStartTime() != null && activity.getStartTime().before(now))
+        {
+            activity.setStatus(1);
+        }
+        else
+        {
+            activity.setStatus(0);
+        }
+        int rows = promotionActivityMapper.updateById(activity);
+        // 删除旧规则
+        ruleMapper.delete(new LambdaQueryWrapper<PromotionActivityRule>().eq(PromotionActivityRule::getActivityId, activityId));
+        // 重新插入新规则
+        for (PromotionActivityRule rule : rules)
+        {
+            rule.setActivityId(activityId);
+            ruleMapper.insert(rule);
+        }
+        return rows > 0;
+    }
+
+    /**
+     * 删除促销活动(仅未开始)
+     */
+    @Override
+    @Transactional
+    public boolean deleteActivity(Long id)
+    {
+        PromotionActivity existing = promotionActivityMapper.selectById(id);
+        if (existing == null || existing.getStatus() != 0)
+        {
+            throw new ServiceException("只能删除未开始的活动");
+        }
+        // 删除规则
+        ruleMapper.delete(new LambdaQueryWrapper<PromotionActivityRule>().eq(PromotionActivityRule::getActivityId, id));
+        // 删除活动
+        return promotionActivityMapper.deleteById(id) > 0;
+    }
+
+    /**
+     * 结束活动(仅进行中)
      */
      */
     @Override
     @Override
     public boolean endActivity(Long id)
     public boolean endActivity(Long id)
     {
     {
+        PromotionActivity existing = promotionActivityMapper.selectById(id);
+        if (existing == null || existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能结束进行中的活动");
+        }
         PromotionActivity activity = new PromotionActivity();
         PromotionActivity activity = new PromotionActivity();
         activity.setId(id);
         activity.setId(id);
         activity.setStatus(2);
         activity.setStatus(2);

+ 795 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java

@@ -0,0 +1,795 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PromotionActivityMapper;
+import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
+import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
+import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
+import com.ruoyi.system.mapper.PromotionUserCouponMapper;
+import com.ruoyi.system.service.IPromotionCalcService;
+
+/**
+ * 促销算价Service实现
+ *
+ * 两路算价:Path A(折扣/第二份半价)vs Path B(满减),取最优路径。
+ * 叠加新客立减、优惠券后得出最终金额。
+ *
+ * @author ruoyi
+ */
+@Service
+public class PromotionCalcServiceImpl implements IPromotionCalcService
+{
+    private static final BigDecimal MIN_AMOUNT = new BigDecimal("0.01");
+
+    @Autowired
+    private PromotionActivityMapper activityMapper;
+
+    @Autowired
+    private PromotionActivityRuleMapper activityRuleMapper;
+
+    @Autowired
+    private PromotionCouponBatchMapper couponBatchMapper;
+
+    @Autowired
+    private PromotionCouponRuleMapper couponRuleMapper;
+
+    @Autowired
+    private PromotionUserCouponMapper userCouponMapper;
+
+    @Autowired
+    private PosFoodMapper posFoodMapper;
+
+    @Autowired
+    private PosOrderMapper posOrderMapper;
+
+    @Override
+    public Map<String, Object> calculate(Long storeId, List<Map<String, Object>> items,
+                                         Long userId, Long couponId, String forcePath)
+    {
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        // ---- 1. 获取商品真实价格,计算 originalAmount ----
+        Map<Long, BigDecimal> priceMap = new HashMap<>();
+        Map<Long, String> nameMap = new HashMap<>();
+        for (Map<String, Object> item : items)
+        {
+            Long productId = toLong(item.get("productId"));
+            if (productId != null && !priceMap.containsKey(productId))
+            {
+                PosFood food = posFoodMapper.selectById(productId);
+                if (food != null)
+                {
+                    priceMap.put(productId, food.getPrice());
+                    nameMap.put(productId, food.getName());
+                }
+            }
+        }
+
+        BigDecimal originalAmount = BigDecimal.ZERO;
+        // itemsWithPrice: [{productId, quantity, unitPrice, name}]
+        List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
+        for (Map<String, Object> item : items)
+        {
+            Long productId = toLong(item.get("productId"));
+            int quantity = toInt(item.get("quantity"));
+            BigDecimal unitPrice = priceMap.getOrDefault(productId, BigDecimal.ZERO);
+            originalAmount = originalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
+
+            Map<String, Object> ip = new LinkedHashMap<>();
+            ip.put("productId", productId);
+            ip.put("quantity", quantity);
+            ip.put("unitPrice", unitPrice);
+            ip.put("name", nameMap.getOrDefault(productId, ""));
+            itemsWithPrice.add(ip);
+        }
+        originalAmount = originalAmount.setScale(2, RoundingMode.HALF_UP);
+        result.put("originalAmount", originalAmount);
+
+        // ---- 2. 查询门店生效的促销活动 ----
+        List<PromotionActivity> activeActivities = activityMapper.selectActiveByStoreId(storeId);
+        List<PromotionActivityRule> activeRules = activityRuleMapper.selectActiveRulesByStoreId(storeId);
+
+        // 按活动类型分组
+        Map<Long, PromotionActivity> activityMap = activeActivities.stream()
+                .collect(Collectors.toMap(PromotionActivity::getId, a -> a, (a, b) -> a));
+
+        // activityId -> rules
+        Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
+                .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
+
+        // productId -> rules (for discount/half-price matching)
+        Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
+                .filter(r -> r.getProductId() != null)
+                .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
+
+        // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
+        Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
+                rulesByActivity, rulesByProduct, activityMap);
+        result.put("pathA", pathAResult);
+
+        // ---- 4. PATH B: 满减(type=1) ----
+        Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
+                activeActivities, rulesByActivity, activityMap);
+        result.put("pathB", pathBResult);
+
+        // ---- 5. 比较两条路径 ----
+        BigDecimal subtotalA = (BigDecimal) pathAResult.get("subtotal");
+        BigDecimal subtotalB = (BigDecimal) pathBResult.get("subtotal");
+
+        String optimalPath;
+        if (forcePath != null && ("A".equalsIgnoreCase(forcePath) || "B".equalsIgnoreCase(forcePath)))
+        {
+            optimalPath = forcePath.toUpperCase();
+        }
+        else
+        {
+            optimalPath = subtotalA.compareTo(subtotalB) < 0 ? "A" : "B";
+        }
+        result.put("optimalPath", optimalPath);
+
+        BigDecimal afterPromotion = "A".equals(optimalPath) ? subtotalA : subtotalB;
+        BigDecimal promotionReduce = originalAmount.subtract(afterPromotion);
+
+        // ---- 6. 新客立减(type=4) ----
+        BigDecimal newCustomerReduce = BigDecimal.ZERO;
+        if (userId != null)
+        {
+            LambdaQueryWrapper<PosOrder> orderWrapper = new LambdaQueryWrapper<>();
+            orderWrapper.eq(PosOrder::getUserId, userId)
+                        .eq(PosOrder::getMdId, storeId)
+                        .eq(PosOrder::getState, 3L); // state=3 表示已完成
+            Long completedCount = posOrderMapper.selectCount(orderWrapper);
+
+            if (completedCount == 0)
+            {
+                // 新客
+                for (PromotionActivity act : activeActivities)
+                {
+                    if (act.getType() != null && act.getType() == 4)
+                    {
+                        List<PromotionActivityRule> ncRules = rulesByActivity.get(act.getId());
+                        if (ncRules != null && !ncRules.isEmpty())
+                        {
+                            BigDecimal reduce = ncRules.get(0).getReduceAmount();
+                            if (reduce != null && reduce.compareTo(BigDecimal.ZERO) > 0)
+                            {
+                                newCustomerReduce = reduce;
+                            }
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+        if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            result.put("newCustomerReduce", newCustomerReduce);
+        }
+
+        // ---- 7. 优惠券 ----
+        BigDecimal couponReduce = BigDecimal.ZERO;
+        String couponName = null;
+        Boolean couponConflict = null;
+        String conflictNote = null;
+
+        if (couponId != null)
+        {
+            PromotionUserCoupon userCoupon = userCouponMapper.selectById(couponId);
+            if (userCoupon != null && userCoupon.getUserId().equals(userId)
+                    && userCoupon.getStatus() != null && userCoupon.getStatus() == 0)
+            {
+                // 检查是否过期
+                if (userCoupon.getExpireTime() != null && userCoupon.getExpireTime().before(new Date()))
+                {
+                    // 已过期,不处理
+                }
+                else
+                {
+                    PromotionCouponBatch batch = couponBatchMapper.selectById(userCoupon.getBatchId());
+                    PromotionCouponRule couponRule = couponRuleMapper.selectRuleByBatchId(userCoupon.getBatchId());
+
+                    if (batch != null && couponRule != null)
+                    {
+                        couponName = batch.getName();
+
+                        int isMutex = couponRule.getIsMutex() != null ? couponRule.getIsMutex() : 0;
+
+                        // 互斥检查
+                        if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+                        {
+                            couponConflict = true;
+                            conflictNote = "互斥券不可与满减/折扣叠加,选择此券将取消促销优惠";
+                            // 互斥券:以原价为基础计算优惠
+                            couponReduce = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap);
+                            // 使用互斥券时,促销优惠取消,以原价减去券优惠
+                            afterPromotion = originalAmount;
+                            promotionReduce = BigDecimal.ZERO;
+                            newCustomerReduce = BigDecimal.ZERO;
+                        }
+                        else
+                        {
+                            // 同享券:基于促销后金额判断门槛
+                            BigDecimal baseForCoupon = afterPromotion.subtract(newCustomerReduce);
+                            baseForCoupon = baseForCoupon.max(BigDecimal.ZERO);
+
+                            // 门槛检查
+                            if (couponRule.getThreshold() != null
+                                    && baseForCoupon.compareTo(couponRule.getThreshold()) >= 0)
+                            {
+                                // 商品券(type=2)特殊检查:商品是否在折扣/半价活动中
+                                if (batch.getCouponType() != null && batch.getCouponType() == 2
+                                        && couponRule.getProductId() != null && "A".equals(optimalPath))
+                                {
+                                    // 检查商品券的商品是否在当前Path A的活动中
+                                    List<PromotionActivityRule> productRules = rulesByProduct.get(couponRule.getProductId());
+                                    if (productRules != null)
+                                    {
+                                        boolean inDiscount = productRules.stream().anyMatch(r ->
+                                        {
+                                            PromotionActivity act = activityMap.get(r.getActivityId());
+                                            return act != null && (act.getType() == 2 || act.getType() == 3);
+                                        });
+                                        if (inDiscount)
+                                        {
+                                            couponConflict = true;
+                                            conflictNote = "商品券对应的商品在折扣活动中,不可与Path A叠加";
+                                        }
+                                    }
+                                }
+
+                                if (couponConflict == null)
+                                {
+                                    couponReduce = calcCouponReduce(couponRule, batch, itemsWithPrice,
+                                            baseForCoupon, priceMap);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        if (couponId != null)
+        {
+            result.put("couponId", couponId);
+        }
+        if (couponName != null)
+        {
+            result.put("couponName", couponName);
+        }
+        if (couponConflict != null)
+        {
+            result.put("couponConflict", couponConflict);
+        }
+        if (conflictNote != null)
+        {
+            result.put("conflictNote", conflictNote);
+        }
+        if (couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            result.put("couponReduce", couponReduce);
+        }
+
+        // ---- 8. 最终金额 ----
+        BigDecimal finalAmount = afterPromotion.subtract(newCustomerReduce).subtract(couponReduce);
+        finalAmount = finalAmount.max(MIN_AMOUNT).setScale(2, RoundingMode.HALF_UP);
+        result.put("finalAmount", finalAmount);
+
+        // ---- 9. 明细列表 ----
+        List<Map<String, Object>> details = new ArrayList<>();
+        if (promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            Map<String, Object> detail = new LinkedHashMap<>();
+            detail.put("type", "promotion");
+            if ("A".equals(optimalPath))
+            {
+                detail.put("subType", pathAResult.get("appliedType"));
+                detail.put("name", pathAResult.get("label"));
+            }
+            else
+            {
+                detail.put("subType", 1);
+                detail.put("name", pathBResult.get("label"));
+            }
+            detail.put("reduce", promotionReduce);
+            details.add(detail);
+        }
+        if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            Map<String, Object> detail = new LinkedHashMap<>();
+            detail.put("type", "promotion");
+            detail.put("subType", 4);
+            detail.put("name", "新客立减");
+            detail.put("reduce", newCustomerReduce);
+            details.add(detail);
+        }
+        if (couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            Map<String, Object> detail = new LinkedHashMap<>();
+            detail.put("type", "coupon");
+            detail.put("subType", 0);
+            detail.put("name", couponName);
+            detail.put("reduce", couponReduce);
+            details.add(detail);
+        }
+        result.put("details", details);
+
+        // ---- 10. 可用优惠券列表 ----
+        List<Map<String, Object>> availableCoupons = buildAvailableCoupons(storeId, userId,
+                originalAmount, afterPromotion, promotionReduce, itemsWithPrice,
+                rulesByProduct, activityMap);
+        result.put("availableCoupons", availableCoupons);
+
+        return result;
+    }
+
+    /**
+     * PATH A: 折扣(type=2) vs 第二份半价(type=3),取更优
+     */
+    private Map<String, Object> calculatePathA(List<Map<String, Object>> itemsWithPrice,
+                                               List<PromotionActivity> activeActivities,
+                                               Map<Long, List<PromotionActivityRule>> rulesByActivity,
+                                               Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                               Map<Long, PromotionActivity> activityMap)
+    {
+        Map<String, Object> pathResult = new LinkedHashMap<>();
+        pathResult.put("label", "折扣");
+        pathResult.put("appliedType", 2);
+
+        // 分别计算折扣和第二份半价
+        Map<String, Object> discountResult = calcDiscount(itemsWithPrice, rulesByProduct, activityMap);
+        Map<String, Object> halfPriceResult = calcHalfPrice(itemsWithPrice, rulesByProduct, activityMap);
+
+        BigDecimal discountSubtotal = (BigDecimal) discountResult.get("subtotal");
+        BigDecimal halfPriceSubtotal = (BigDecimal) halfPriceResult.get("subtotal");
+
+        if (halfPriceSubtotal.compareTo(discountSubtotal) < 0)
+        {
+            // 第二份半价更优
+            pathResult.put("label", "第二份半价");
+            pathResult.put("appliedType", 3);
+            pathResult.put("items", halfPriceResult.get("items"));
+            pathResult.put("subtotal", halfPriceSubtotal);
+            pathResult.put("promotionReduce",
+                    discountResult.containsKey("originalRef")
+                            ? ((BigDecimal) discountResult.get("originalRef")).subtract(halfPriceSubtotal)
+                                    .setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO);
+        }
+        else
+        {
+            // 折扣更优或相等
+            pathResult.put("items", discountResult.get("items"));
+            pathResult.put("subtotal", discountSubtotal);
+            pathResult.put("promotionReduce",
+                    discountResult.containsKey("originalRef")
+                            ? ((BigDecimal) discountResult.get("originalRef")).subtract(discountSubtotal)
+                                    .setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO);
+        }
+
+        return pathResult;
+    }
+
+    /**
+     * 折扣计算: 匹配到商品的规则 → unitPrice * discountRate
+     */
+    private Map<String, Object> calcDiscount(List<Map<String, Object>> itemsWithPrice,
+                                             Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                             Map<Long, PromotionActivity> activityMap)
+    {
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> itemList = new ArrayList<>();
+        BigDecimal subtotal = BigDecimal.ZERO;
+        BigDecimal originalRef = BigDecimal.ZERO;
+
+        for (Map<String, Object> item : itemsWithPrice)
+        {
+            Long productId = toLong(item.get("productId"));
+            int quantity = toInt(item.get("quantity"));
+            BigDecimal unitPrice = (BigDecimal) item.get("unitPrice");
+
+            BigDecimal lineOriginal = unitPrice.multiply(BigDecimal.valueOf(quantity));
+            originalRef = originalRef.add(lineOriginal);
+
+            // 查找此商品是否有折扣规则
+            BigDecimal finalPrice = lineOriginal;
+            PromotionActivityRule matchedRule = null;
+
+            List<PromotionActivityRule> productRules = rulesByProduct.get(productId);
+            if (productRules != null)
+            {
+                for (PromotionActivityRule rule : productRules)
+                {
+                    PromotionActivity act = activityMap.get(rule.getActivityId());
+                    if (act != null && act.getType() == 2 && rule.getDiscountRate() != null)
+                    {
+                        // 满足最低数量
+                        if (rule.getMinQuantity() == null || quantity >= rule.getMinQuantity())
+                        {
+                            BigDecimal discounted = unitPrice.multiply(rule.getDiscountRate())
+                                    .multiply(BigDecimal.valueOf(quantity))
+                                    .setScale(2, RoundingMode.HALF_UP);
+                            if (discounted.compareTo(finalPrice) < 0)
+                            {
+                                finalPrice = discounted;
+                                matchedRule = rule;
+                            }
+                        }
+                    }
+                }
+            }
+
+            Map<String, Object> lineItem = new LinkedHashMap<>();
+            lineItem.put("productId", productId);
+            lineItem.put("quantity", quantity);
+            lineItem.put("unitPrice", unitPrice);
+            lineItem.put("originalLineTotal", lineOriginal.setScale(2, RoundingMode.HALF_UP));
+            lineItem.put("finalLineTotal", finalPrice.setScale(2, RoundingMode.HALF_UP));
+            lineItem.put("name", item.get("name"));
+            if (matchedRule != null)
+            {
+                lineItem.put("discountRate", matchedRule.getDiscountRate());
+            }
+            itemList.add(lineItem);
+            subtotal = subtotal.add(finalPrice);
+        }
+
+        result.put("items", itemList);
+        result.put("subtotal", subtotal.setScale(2, RoundingMode.HALF_UP));
+        result.put("originalRef", originalRef.setScale(2, RoundingMode.HALF_UP));
+        return result;
+    }
+
+    /**
+     * 第二份半价计算: 每两件中的第二件按50%
+     */
+    private Map<String, Object> calcHalfPrice(List<Map<String, Object>> itemsWithPrice,
+                                              Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                              Map<Long, PromotionActivity> activityMap)
+    {
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> itemList = new ArrayList<>();
+        BigDecimal subtotal = BigDecimal.ZERO;
+        BigDecimal originalRef = BigDecimal.ZERO;
+
+        for (Map<String, Object> item : itemsWithPrice)
+        {
+            Long productId = toLong(item.get("productId"));
+            int quantity = toInt(item.get("quantity"));
+            BigDecimal unitPrice = (BigDecimal) item.get("unitPrice");
+
+            BigDecimal lineOriginal = unitPrice.multiply(BigDecimal.valueOf(quantity));
+            originalRef = originalRef.add(lineOriginal);
+
+            BigDecimal finalPrice = lineOriginal;
+            PromotionActivityRule matchedRule = null;
+
+            List<PromotionActivityRule> productRules = rulesByProduct.get(productId);
+            if (productRules != null)
+            {
+                for (PromotionActivityRule rule : productRules)
+                {
+                    PromotionActivity act = activityMap.get(rule.getActivityId());
+                    if (act != null && act.getType() == 3)
+                    {
+                        if (rule.getMinQuantity() == null || quantity >= rule.getMinQuantity())
+                        {
+                            // 每2件中第2件半价: pairs * 1.5 * unitPrice + remainder * unitPrice
+                            int pairs = quantity / 2;
+                            int remainder = quantity % 2;
+                            BigDecimal halfPriceTotal = unitPrice.multiply(BigDecimal.valueOf(pairs))
+                                    .multiply(new BigDecimal("1.5"))
+                                    .add(unitPrice.multiply(BigDecimal.valueOf(remainder)))
+                                    .setScale(2, RoundingMode.HALF_UP);
+                            if (halfPriceTotal.compareTo(finalPrice) < 0)
+                            {
+                                finalPrice = halfPriceTotal;
+                                matchedRule = rule;
+                            }
+                        }
+                    }
+                }
+            }
+
+            Map<String, Object> lineItem = new LinkedHashMap<>();
+            lineItem.put("productId", productId);
+            lineItem.put("quantity", quantity);
+            lineItem.put("unitPrice", unitPrice);
+            lineItem.put("originalLineTotal", lineOriginal.setScale(2, RoundingMode.HALF_UP));
+            lineItem.put("finalLineTotal", finalPrice.setScale(2, RoundingMode.HALF_UP));
+            lineItem.put("name", item.get("name"));
+            if (matchedRule != null)
+            {
+                lineItem.put("halfPriceApplied", true);
+            }
+            itemList.add(lineItem);
+            subtotal = subtotal.add(finalPrice);
+        }
+
+        result.put("items", itemList);
+        result.put("subtotal", subtotal.setScale(2, RoundingMode.HALF_UP));
+        result.put("originalRef", originalRef.setScale(2, RoundingMode.HALF_UP));
+        return result;
+    }
+
+    /**
+     * PATH B: 满减(type=1) — 找最高匹配门槛的规则
+     */
+    private Map<String, Object> calculatePathB(List<Map<String, Object>> itemsWithPrice,
+                                               BigDecimal originalAmount,
+                                               List<PromotionActivity> activeActivities,
+                                               Map<Long, List<PromotionActivityRule>> rulesByActivity,
+                                               Map<Long, PromotionActivity> activityMap)
+    {
+        Map<String, Object> pathResult = new LinkedHashMap<>();
+        pathResult.put("label", "满减");
+
+        List<Map<String, Object>> itemList = new ArrayList<>();
+        for (Map<String, Object> item : itemsWithPrice)
+        {
+            Map<String, Object> lineItem = new LinkedHashMap<>();
+            lineItem.put("productId", item.get("productId"));
+            lineItem.put("quantity", item.get("quantity"));
+            lineItem.put("unitPrice", item.get("unitPrice"));
+            BigDecimal lineTotal = ((BigDecimal) item.get("unitPrice"))
+                    .multiply(BigDecimal.valueOf(toInt(item.get("quantity"))))
+                    .setScale(2, RoundingMode.HALF_UP);
+            lineItem.put("originalLineTotal", lineTotal);
+            lineItem.put("finalLineTotal", lineTotal);
+            lineItem.put("name", item.get("name"));
+            itemList.add(lineItem);
+        }
+        pathResult.put("items", itemList);
+
+        // 查找满减活动
+        PromotionActivityRule bestMatch = null;
+        PromotionActivity bestActivity = null;
+
+        for (PromotionActivity act : activeActivities)
+        {
+            if (act.getType() != null && act.getType() == 1)
+            {
+                List<PromotionActivityRule> rules = rulesByActivity.get(act.getId());
+                if (rules != null)
+                {
+                    for (PromotionActivityRule rule : rules)
+                    {
+                        if (rule.getThreshold() != null && rule.getReduceAmount() != null
+                                && originalAmount.compareTo(rule.getThreshold()) >= 0)
+                        {
+                            if (bestMatch == null || rule.getThreshold().compareTo(bestMatch.getThreshold()) > 0)
+                            {
+                                bestMatch = rule;
+                                bestActivity = act;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        if (bestMatch != null)
+        {
+            BigDecimal subtotal = originalAmount.subtract(bestMatch.getReduceAmount())
+                    .max(MIN_AMOUNT).setScale(2, RoundingMode.HALF_UP);
+            pathResult.put("subtotal", subtotal);
+            pathResult.put("promotionReduce", bestMatch.getReduceAmount());
+            Map<String, Object> matchedRuleInfo = new LinkedHashMap<>();
+            matchedRuleInfo.put("id", bestMatch.getId());
+            matchedRuleInfo.put("activityId", bestMatch.getActivityId());
+            matchedRuleInfo.put("threshold", bestMatch.getThreshold());
+            matchedRuleInfo.put("reduceAmount", bestMatch.getReduceAmount());
+            if (bestActivity != null)
+            {
+                matchedRuleInfo.put("activityName", bestActivity.getName());
+            }
+            pathResult.put("matchedRule", matchedRuleInfo);
+        }
+        else
+        {
+            pathResult.put("subtotal", originalAmount);
+            pathResult.put("promotionReduce", BigDecimal.ZERO);
+        }
+
+        return pathResult;
+    }
+
+    /**
+     * 计算单张券的减免金额
+     */
+    private BigDecimal calcCouponReduce(PromotionCouponRule couponRule, PromotionCouponBatch batch,
+                                        List<Map<String, Object>> itemsWithPrice,
+                                        BigDecimal baseAmount, Map<Long, BigDecimal> priceMap)
+    {
+        if (batch.getCouponType() != null && batch.getCouponType() == 2)
+        {
+            // 商品券: 基于商品原价和折扣率
+            if (couponRule.getProductId() != null)
+            {
+                BigDecimal productPrice = priceMap.getOrDefault(couponRule.getProductId(), BigDecimal.ZERO);
+                // 找到此商品在购物车中的数量
+                int quantity = 0;
+                for (Map<String, Object> item : itemsWithPrice)
+                {
+                    if (couponRule.getProductId().equals(toLong(item.get("productId"))))
+                    {
+                        quantity = toInt(item.get("quantity"));
+                        break;
+                    }
+                }
+                BigDecimal totalProductPrice = productPrice.multiply(BigDecimal.valueOf(quantity));
+                if (couponRule.getDiscountRate() != null)
+                {
+                    BigDecimal reduction = totalProductPrice.multiply(BigDecimal.ONE.subtract(couponRule.getDiscountRate()));
+                    return reduction.max(BigDecimal.ZERO).min(totalProductPrice)
+                            .setScale(2, RoundingMode.HALF_UP);
+                }
+                else if (couponRule.getAmount() != null)
+                {
+                    return couponRule.getAmount().min(totalProductPrice)
+                            .setScale(2, RoundingMode.HALF_UP);
+                }
+            }
+            return BigDecimal.ZERO;
+        }
+        else
+        {
+            // 满减券: 直接减 amount
+            if (couponRule.getAmount() != null)
+            {
+                return couponRule.getAmount().min(baseAmount).setScale(2, RoundingMode.HALF_UP);
+            }
+        }
+        return BigDecimal.ZERO;
+    }
+
+    /**
+     * 构建用户可用优惠券列表
+     */
+    private List<Map<String, Object>> buildAvailableCoupons(Long storeId, Long userId,
+                                                            BigDecimal originalAmount,
+                                                            BigDecimal afterPromotion,
+                                                            BigDecimal promotionReduce,
+                                                            List<Map<String, Object>> itemsWithPrice,
+                                                            Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                                            Map<Long, PromotionActivity> activityMap)
+    {
+        List<Map<String, Object>> available = new ArrayList<>();
+        if (userId == null)
+        {
+            return available;
+        }
+
+        // 查询用户未使用、未过期的优惠券
+        LambdaQueryWrapper<PromotionUserCoupon> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PromotionUserCoupon::getUserId, userId)
+               .eq(PromotionUserCoupon::getStoreId, storeId)
+               .eq(PromotionUserCoupon::getStatus, 0)
+               .gt(PromotionUserCoupon::getExpireTime, new Date());
+
+        List<PromotionUserCoupon> userCoupons = userCouponMapper.selectList(wrapper);
+        for (PromotionUserCoupon uc : userCoupons)
+        {
+            PromotionCouponBatch batch = couponBatchMapper.selectById(uc.getBatchId());
+            if (batch == null || batch.getStatus() == null || batch.getStatus() != 1)
+            {
+                continue;
+            }
+            PromotionCouponRule rule = couponRuleMapper.selectRuleByBatchId(uc.getBatchId());
+            if (rule == null)
+            {
+                continue;
+            }
+
+            Map<String, Object> couponInfo = new LinkedHashMap<>();
+            couponInfo.put("id", uc.getId());
+            couponInfo.put("name", batch.getName());
+            couponInfo.put("couponType", batch.getCouponType());
+            couponInfo.put("isMutex", rule.getIsMutex());
+            couponInfo.put("threshold", rule.getThreshold());
+            couponInfo.put("amount", rule.getAmount());
+            couponInfo.put("discountRate", rule.getDiscountRate());
+
+            // 判断是否可用
+            boolean usable = true;
+            int isMutex = rule.getIsMutex() != null ? rule.getIsMutex() : 0;
+
+            if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+            {
+                // 互斥券且当前有促销优惠 → 仍返回但标记
+                couponInfo.put("conflictWithPromotion", true);
+            }
+
+            // 门槛检查
+            if (rule.getThreshold() != null)
+            {
+                BigDecimal baseForCheck = isMutex == 1 ? originalAmount : afterPromotion;
+                if (baseForCheck.compareTo(rule.getThreshold()) < 0)
+                {
+                    usable = false;
+                }
+            }
+
+            // 商品券: 检查商品是否在购物车中
+            if (batch.getCouponType() != null && batch.getCouponType() == 2 && rule.getProductId() != null)
+            {
+                boolean inCart = itemsWithPrice.stream()
+                        .anyMatch(i -> rule.getProductId().equals(toLong(i.get("productId"))));
+                if (!inCart)
+                {
+                    usable = false;
+                }
+            }
+
+            couponInfo.put("usable", usable);
+            available.add(couponInfo);
+        }
+
+        return available;
+    }
+
+    private Long toLong(Object obj)
+    {
+        if (obj == null)
+        {
+            return null;
+        }
+        if (obj instanceof Long)
+        {
+            return (Long) obj;
+        }
+        if (obj instanceof Number)
+        {
+            return ((Number) obj).longValue();
+        }
+        try
+        {
+            return Long.parseLong(obj.toString());
+        }
+        catch (NumberFormatException e)
+        {
+            return null;
+        }
+    }
+
+    private int toInt(Object obj)
+    {
+        if (obj == null)
+        {
+            return 0;
+        }
+        if (obj instanceof Number)
+        {
+            return ((Number) obj).intValue();
+        }
+        try
+        {
+            return Integer.parseInt(obj.toString());
+        }
+        catch (NumberFormatException e)
+        {
+            return 0;
+        }
+    }
+}

+ 156 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java

@@ -1,11 +1,14 @@
 package com.ruoyi.system.service.impl;
 package com.ruoyi.system.service.impl;
 
 
 import java.util.Date;
 import java.util.Date;
+import java.util.List;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
 import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
 import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
 import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponBatch;
@@ -57,6 +60,37 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
         }
         }
         return rows > 0;
         return rows > 0;
     }
     }
+    /**
+     * 创建优惠券批次(含多条规则,多商品券)
+     */
+    @Override
+    @Transactional
+    public boolean createBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules)
+    {
+        Date now = new Date();
+        batch.setRemainCount(batch.getTotalCount());
+        batch.setReceivedCount(0);
+        batch.setCreateTime(now);
+        batch.setUpdateTime(now);
+        if (batch.getStartTime() != null && batch.getStartTime().before(now))
+        {
+            batch.setStatus(1);
+        }
+        else
+        {
+            batch.setStatus(0);
+        }
+        int rows = promotionCouponBatchMapper.insert(batch);
+        if (rules != null && !rules.isEmpty())
+        {
+            for (PromotionCouponRule r : rules)
+            {
+                r.setBatchId(batch.getId());
+                ruleMapper.insert(r);
+            }
+        }
+        return rows > 0;
+    }
 
 
     /**
     /**
      * 查询批次详情(含规则)
      * 查询批次详情(含规则)
@@ -69,20 +103,141 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
         {
         {
             PromotionCouponRule rule = ruleMapper.selectRuleByBatchId(id);
             PromotionCouponRule rule = ruleMapper.selectRuleByBatchId(id);
             batch.setRule(rule);
             batch.setRule(rule);
+            List<PromotionCouponRule> rules = ruleMapper.selectRulesByBatchId(id);
+            batch.setRules(rules);
         }
         }
         return batch;
         return batch;
     }
     }
 
 
     /**
     /**
-     * 下架批次
+     * 下架批次(仅进行中)
      */
      */
     @Override
     @Override
     public boolean offShelfBatch(Long id)
     public boolean offShelfBatch(Long id)
     {
     {
+        PromotionCouponBatch existing = promotionCouponBatchMapper.selectById(id);
+        if (existing == null || existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能下架进行中的优惠券");
+        }
         PromotionCouponBatch batch = new PromotionCouponBatch();
         PromotionCouponBatch batch = new PromotionCouponBatch();
         batch.setId(id);
         batch.setId(id);
         batch.setStatus(3);
         batch.setStatus(3);
         batch.setUpdateTime(new Date());
         batch.setUpdateTime(new Date());
         return promotionCouponBatchMapper.updateById(batch) > 0;
         return promotionCouponBatchMapper.updateById(batch) > 0;
     }
     }
+
+    /**
+     * 修改优惠券批次(含规则)
+     */
+    @Override
+    @Transactional
+    public boolean updateBatch(PromotionCouponBatch batch, PromotionCouponRule rule)
+    {
+        PromotionCouponBatch existing = promotionCouponBatchMapper.selectById(batch.getId());
+        if (existing == null)
+        {
+            throw new ServiceException("优惠券不存在");
+        }
+        if (existing.getStatus() != 0 && existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能修改未开始或进行中的优惠券");
+        }
+        batch.setUpdateTime(new Date());
+        // 如果修改了总量,同步调整剩余数量
+        if (batch.getTotalCount() != null && !batch.getTotalCount().equals(existing.getTotalCount()))
+        {
+            int delta = batch.getTotalCount() - (existing.getTotalCount() != null ? existing.getTotalCount() : 0);
+            batch.setRemainCount((existing.getRemainCount() != null ? existing.getRemainCount() : 0) + delta);
+        }
+        if (existing.getReceivedCount() != null && existing.getReceivedCount() > 0)
+        {
+            // 已有人领取,只更新批次级别字段,不修改规则
+            promotionCouponBatchMapper.updateById(batch);
+        }
+        else
+        {
+            // 无人领取,更新批次并替换规则
+            promotionCouponBatchMapper.updateById(batch);
+            if (rule != null)
+            {
+                // 删除旧规则
+                ruleMapper.delete(new LambdaQueryWrapper<PromotionCouponRule>()
+                        .eq(PromotionCouponRule::getBatchId, batch.getId()));
+                // 插入新规则
+                rule.setBatchId(batch.getId());
+                ruleMapper.insert(rule);
+            }
+        }
+        return true;
+    }
+    /**
+     * 修改优惠券批次(含多条规则,多商品券)
+     */
+    @Override
+    @Transactional
+    public boolean updateBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules)
+    {
+        PromotionCouponBatch existing = promotionCouponBatchMapper.selectById(batch.getId());
+        if (existing == null)
+        {
+            throw new ServiceException("优惠券不存在");
+        }
+        if (existing.getStatus() != 0 && existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能修改未开始或进行中的优惠券");
+        }
+        batch.setUpdateTime(new Date());
+        if (batch.getTotalCount() != null && !batch.getTotalCount().equals(existing.getTotalCount()))
+        {
+            int delta = batch.getTotalCount() - (existing.getTotalCount() != null ? existing.getTotalCount() : 0);
+            batch.setRemainCount((existing.getRemainCount() != null ? existing.getRemainCount() : 0) + delta);
+        }
+        if (existing.getReceivedCount() != null && existing.getReceivedCount() > 0)
+        {
+            promotionCouponBatchMapper.updateById(batch);
+        }
+        else
+        {
+            promotionCouponBatchMapper.updateById(batch);
+            if (rules != null && !rules.isEmpty())
+            {
+                // 删除旧规则
+                ruleMapper.delete(new LambdaQueryWrapper<PromotionCouponRule>()
+                        .eq(PromotionCouponRule::getBatchId, batch.getId()));
+                // 插入新规则(多条)
+                for (PromotionCouponRule r : rules)
+                {
+                    r.setBatchId(batch.getId());
+                    ruleMapper.insert(r);
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 删除优惠券批次(仅未开始且无人领取)
+     */
+    @Override
+    @Transactional
+    public boolean deleteBatch(Long id)
+    {
+        PromotionCouponBatch existing = promotionCouponBatchMapper.selectById(id);
+        if (existing == null)
+        {
+            throw new ServiceException("优惠券不存在");
+        }
+        boolean hasReceived = existing.getReceivedCount() != null && existing.getReceivedCount() > 0;
+        if (hasReceived || (existing.getStatus() != 0 && existing.getStatus() != 1))
+        {
+            throw new ServiceException("只能删除未开始或进行中且无人领取的优惠券");
+        }
+        // 删除规则
+        ruleMapper.delete(new LambdaQueryWrapper<PromotionCouponRule>()
+                .eq(PromotionCouponRule::getBatchId, id));
+        // 删除批次
+        promotionCouponBatchMapper.deleteById(id);
+        return true;
+    }
 }
 }

+ 185 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionUserCouponServiceImpl.java

@@ -0,0 +1,185 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
+import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
+import com.ruoyi.system.mapper.PromotionUserCouponMapper;
+import com.ruoyi.system.service.IPromotionUserCouponService;
+
+/**
+ * 用户优惠券Service业务层处理
+ */
+@Service
+public class PromotionUserCouponServiceImpl extends ServiceImpl<PromotionUserCouponMapper, PromotionUserCoupon> implements IPromotionUserCouponService {
+
+    @Autowired
+    private PromotionUserCouponMapper userCouponMapper;
+
+    @Autowired
+    private PromotionCouponBatchMapper batchMapper;
+
+    @Autowired
+    private PromotionCouponRuleMapper ruleMapper;
+
+    @Override
+    @Transactional
+    public PromotionUserCoupon receiveCoupon(Long userId, Long batchId, Long storeId) {
+        // 1. 查询券批次,校验状态
+        PromotionCouponBatch batch = batchMapper.selectById(batchId);
+        if (batch == null || batch.getStatus() != 1) {
+            throw new ServiceException("优惠券不可用");
+        }
+        if (batch.getRemainCount() <= 0) {
+            throw new ServiceException("优惠券已领完");
+        }
+        if (batch.getEndTime().before(new Date())) {
+            throw new ServiceException("优惠券已过期");
+        }
+
+        // 2. 检查是否已领取(每人每批次限领1张)
+        LambdaQueryWrapper<PromotionUserCoupon> countWrapper = new LambdaQueryWrapper<>();
+        countWrapper.eq(PromotionUserCoupon::getUserId, userId)
+                .eq(PromotionUserCoupon::getBatchId, batchId);
+        long count = userCouponMapper.selectCount(countWrapper);
+        if (count > 0) {
+            throw new ServiceException("您已领取过该优惠券");
+        }
+
+        // 3. 乐观锁扣减库存
+        int updated = batchMapper.update(null,
+            new LambdaUpdateWrapper<PromotionCouponBatch>()
+                .eq(PromotionCouponBatch::getId, batchId)
+                .gt(PromotionCouponBatch::getRemainCount, 0)
+                .setSql("remain_count = remain_count - 1, received_count = received_count + 1")
+        );
+        if (updated == 0) {
+            throw new ServiceException("优惠券已领完");
+        }
+
+        // 4. 计算过期时间:领取日期 + validDays天后的 23:59:59
+        LocalDateTime expireTime = LocalDate.now().plusDays(batch.getValidDays()).atTime(LocalTime.of(23, 59, 59));
+
+        // 5. 插入用户券记录
+        PromotionUserCoupon coupon = new PromotionUserCoupon();
+        coupon.setUserId(userId);
+        coupon.setBatchId(batchId);
+        coupon.setStoreId(storeId != null ? storeId : batch.getStoreId());
+        coupon.setStatus(0);
+        coupon.setReceiveTime(new Date());
+        coupon.setExpireTime(java.sql.Timestamp.valueOf(expireTime));
+        userCouponMapper.insert(coupon);
+
+        return coupon;
+    }
+
+    @Override
+    public List<Map<String, Object>> selectStoreCouponsForUser(Long userId, Long storeId) {
+        // 查询门店进行中的券批次
+        LambdaQueryWrapper<PromotionCouponBatch> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PromotionCouponBatch::getStoreId, storeId)
+                .eq(PromotionCouponBatch::getStatus, 1)
+                .le(PromotionCouponBatch::getStartTime, new Date())
+                .ge(PromotionCouponBatch::getEndTime, new Date());
+        List<PromotionCouponBatch> batches = batchMapper.selectList(wrapper);
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (PromotionCouponBatch batch : batches) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", batch.getId());
+            item.put("name", batch.getName());
+            item.put("couponType", batch.getCouponType());
+            item.put("totalCount", batch.getTotalCount());
+            item.put("remainCount", batch.getRemainCount());
+
+            // 查询券规则
+            PromotionCouponRule rule = ruleMapper.selectOne(
+                new LambdaQueryWrapper<PromotionCouponRule>()
+                    .eq(PromotionCouponRule::getBatchId, batch.getId()));
+            if (rule != null) {
+                item.put("threshold", rule.getThreshold());
+                item.put("amount", rule.getAmount());
+                item.put("discountRate", rule.getDiscountRate());
+                item.put("isMutex", rule.getIsMutex());
+            }
+
+            // 检查用户是否已领取
+            long received = userCouponMapper.selectCount(
+                new LambdaQueryWrapper<PromotionUserCoupon>()
+                    .eq(PromotionUserCoupon::getUserId, userId)
+                    .eq(PromotionUserCoupon::getBatchId, batch.getId()));
+            item.put("hasReceived", received > 0);
+
+            result.add(item);
+        }
+        return result;
+    }
+
+    @Override
+    public List<Map<String, Object>> selectMyCoupons(Long userId, Integer status, Long storeId) {
+        // 先把已过期的券标记为过期(expire_time < NOW() 且 status=0)
+        userCouponMapper.update(null,
+            new LambdaUpdateWrapper<PromotionUserCoupon>()
+                .eq(PromotionUserCoupon::getUserId, userId)
+                .eq(PromotionUserCoupon::getStatus, 0)
+                .lt(PromotionUserCoupon::getExpireTime, new Date())
+                .set(PromotionUserCoupon::getStatus, 2)
+        );
+
+        // 查询用户券
+        LambdaQueryWrapper<PromotionUserCoupon> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PromotionUserCoupon::getUserId, userId);
+        if (status != null) {
+            wrapper.eq(PromotionUserCoupon::getStatus, status);
+        }
+        if (storeId != null) {
+            wrapper.eq(PromotionUserCoupon::getStoreId, storeId);
+        }
+        wrapper.orderByDesc(PromotionUserCoupon::getReceiveTime);
+        List<PromotionUserCoupon> coupons = userCouponMapper.selectList(wrapper);
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (PromotionUserCoupon coupon : coupons) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", coupon.getId());
+            item.put("batchId", coupon.getBatchId());
+            item.put("storeId", coupon.getStoreId());
+            item.put("status", coupon.getStatus());
+            item.put("expireTime", coupon.getExpireTime());
+
+            // 查询批次信息
+            PromotionCouponBatch batch = batchMapper.selectById(coupon.getBatchId());
+            if (batch != null) {
+                item.put("name", batch.getName());
+                item.put("couponType", batch.getCouponType());
+            }
+
+            // 查询规则信息
+            PromotionCouponRule rule = ruleMapper.selectOne(
+                new LambdaQueryWrapper<PromotionCouponRule>()
+                    .eq(PromotionCouponRule::getBatchId, coupon.getBatchId()));
+            if (rule != null) {
+                item.put("threshold", rule.getThreshold());
+                item.put("amount", rule.getAmount());
+                item.put("discountRate", rule.getDiscountRate());
+                item.put("isMutex", rule.getIsMutex());
+            }
+
+            result.add(item);
+        }
+        return result;
+    }
+}

+ 42 - 0
ruoyi-system/src/main/resources/mapper/system/PosOrderPromotionMapper.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderPromotionMapper">
+
+    <resultMap type="PosOrderPromotion" id="PosOrderPromotionResult">
+        <result property="id"             column="id" />
+        <result property="orderId"        column="order_id" />
+        <result property="promoType"      column="promo_type" />
+        <result property="promoSubType"   column="promo_sub_type" />
+        <result property="promoId"        column="promo_id" />
+        <result property="userCouponId"   column="user_coupon_id" />
+        <result property="promoName"      column="promo_name" />
+        <result property="promoDetail"    column="promo_detail" />
+        <result property="reduceAmount"   column="reduce_amount" />
+        <result property="pathSummary"    column="path_summary" />
+        <result property="createTime"     column="create_time" />
+    </resultMap>
+
+    <sql id="selectPosOrderPromotionVo">
+        select id, order_id, promo_type, promo_sub_type, promo_id, user_coupon_id,
+               promo_name, promo_detail, reduce_amount, path_summary, create_time
+        from pos_order_promotion
+    </sql>
+
+    <select id="selectByOrderId" parameterType="Long" resultMap="PosOrderPromotionResult">
+        <include refid="selectPosOrderPromotionVo"/>
+        where order_id = #{orderId}
+        order by id asc
+    </select>
+
+    <insert id="insertBatch" parameterType="java.util.List">
+        insert into pos_order_promotion (order_id, promo_type, promo_sub_type, promo_id,
+            user_coupon_id, promo_name, promo_detail, reduce_amount, path_summary)
+        values
+        <foreach item="item" collection="list" separator=",">
+            (#{item.orderId}, #{item.promoType}, #{item.promoSubType}, #{item.promoId},
+             #{item.userCouponId}, #{item.promoName}, #{item.promoDetail}, #{item.reduceAmount},
+             #{item.pathSummary})
+        </foreach>
+    </insert>
+
+</mapper>

+ 77 - 12
ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml

@@ -1,19 +1,84 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 <?xml version="1.0" encoding="UTF-8" ?>
 <!DOCTYPE mapper
 <!DOCTYPE mapper
-        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
-        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.system.mapper.PromotionActivityMapper">
 <mapper namespace="com.ruoyi.system.mapper.PromotionActivityMapper">
 
 
     <resultMap type="PromotionActivity" id="PromotionActivityResult">
     <resultMap type="PromotionActivity" id="PromotionActivityResult">
-        <result property="id"    column="id"    />
-        <result property="storeId"    column="store_id"    />
-        <result property="type"    column="type"    />
-        <result property="name"    column="name"    />
-        <result property="status"    column="status"    />
-        <result property="startTime"    column="start_time"    />
-        <result property="endTime"    column="end_time"    />
-        <result property="createTime"    column="create_time"    />
-        <result property="updateTime"    column="update_time"    />
+        <result property="id" column="id" />
+        <result property="storeId" column="store_id" />
+        <result property="type" column="type" />
+        <result property="name" column="name" />
+        <result property="status" column="status" />
+        <result property="startTime" column="start_time" />
+        <result property="endTime" column="end_time" />
+        <result property="createTime" column="create_time" />
+        <result property="updateTime" column="update_time" />
     </resultMap>
     </resultMap>
 
 
-</mapper>
+    <sql id="selectPromotionActivityVo">
+        select id, store_id, type, name, status, start_time, end_time, create_time, update_time
+        from promotion_activity
+    </sql>
+
+    <select id="selectPromotionActivityList" parameterType="PromotionActivity" resultMap="PromotionActivityResult">
+        <include refid="selectPromotionActivityVo"/>
+        <where>
+            <if test="storeId != null">and store_id = #{storeId}</if>
+            <if test="type != null">and type = #{type}</if>
+            <if test="status != null">and status = #{status}</if>
+        </where>
+        order by id desc
+    </select>
+
+    <select id="selectPromotionActivityById" parameterType="Long" resultMap="PromotionActivityResult">
+        <include refid="selectPromotionActivityVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertPromotionActivity" parameterType="PromotionActivity" useGeneratedKeys="true" keyProperty="id">
+        insert into promotion_activity
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="storeId != null">store_id,</if>
+            <if test="type != null">type,</if>
+            <if test="name != null">name,</if>
+            <if test="status != null">status,</if>
+            <if test="startTime != null">start_time,</if>
+            <if test="endTime != null">end_time,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="storeId != null">#{storeId},</if>
+            <if test="type != null">#{type},</if>
+            <if test="name != null">#{name},</if>
+            <if test="status != null">#{status},</if>
+            <if test="startTime != null">#{startTime},</if>
+            <if test="endTime != null">#{endTime},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+        </trim>
+    </insert>
+
+    <update id="updatePromotionActivity" parameterType="PromotionActivity">
+        update promotion_activity
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="storeId != null">store_id = #{storeId},</if>
+            <if test="type != null">type = #{type},</if>
+            <if test="name != null">name = #{name},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="endTime != null">end_time = #{endTime},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <select id="selectActiveByStoreId" parameterType="Long" resultMap="PromotionActivityResult">
+        <include refid="selectPromotionActivityVo"/>
+        where store_id = #{storeId} and status = 1
+        order by type asc
+    </select>
+
+</mapper>

+ 9 - 0
ruoyi-system/src/main/resources/mapper/system/PromotionActivityRuleMapper.xml

@@ -29,4 +29,13 @@
         ORDER BY r.id ASC
         ORDER BY r.id ASC
     </select>
     </select>
 
 
+    <select id="selectActiveRulesByStoreId" parameterType="Long" resultMap="PromotionActivityRuleResult">
+        SELECT r.*, f.name AS product_name, f.image AS product_image, f.price AS product_price
+        FROM promotion_activity_rule r
+        INNER JOIN promotion_activity a ON r.activity_id = a.id AND a.status = 1
+        LEFT JOIN pos_food f ON r.product_id = f.id
+        WHERE a.store_id = #{storeId}
+        ORDER BY a.type ASC, r.id ASC
+    </select>
+
 </mapper>
 </mapper>

+ 7 - 0
ruoyi-system/src/main/resources/mapper/system/PromotionCouponBatchMapper.xml

@@ -20,4 +20,11 @@
         <result property="updateTime"    column="update_time"    />
         <result property="updateTime"    column="update_time"    />
     </resultMap>
     </resultMap>
 
 
+    <select id="selectActiveByStoreId" parameterType="Long" resultMap="PromotionCouponBatchResult">
+        select id, store_id, name, coupon_type, total_count, remain_count, received_count, status, start_time, end_time, valid_days, create_time, update_time
+        from promotion_coupon_batch
+        where store_id = #{storeId} and status = 1 and remain_count &gt; 0
+        order by id asc
+    </select>
+
 </mapper>
 </mapper>

+ 11 - 0
ruoyi-system/src/main/resources/mapper/system/PromotionCouponRuleMapper.xml

@@ -29,4 +29,15 @@
         LIMIT 1
         LIMIT 1
     </select>
     </select>
 
 
+    <select id="selectRulesByBatchId" parameterType="Long" resultMap="PromotionCouponRuleResult">
+        SELECT
+            r.*,
+            f.name AS product_name,
+            f.image AS product_image,
+            f.price AS product_price
+        FROM promotion_coupon_rule r
+        LEFT JOIN pos_food f ON r.product_id = f.id
+        WHERE r.batch_id = #{batchId}
+    </select>
+
 </mapper>
 </mapper>

+ 52 - 0
specs/008-promotion-coupon/checklists/calc-logic.md

@@ -0,0 +1,52 @@
+# Checklist: 算价逻辑 — 专项需求质量审查
+
+**Purpose**: 算价是金钱相关核心逻辑,专项审查确保计算规则的完整性、正确性、无歧义
+**Created**: 2026-06-01
+**Feature**: 008-promotion-coupon — Phase 5.5 算价接口
+**Depth**: Standard | **Actor**: Author (pre-implementation)
+
+---
+
+## 路径计算规则
+
+- [ ] CHK001 — 路径A(走折扣)的计算规则是否覆盖了"部分商品有折扣、部分没有"的场景? [Completeness, Spec §FR-017]
+- [ ] CHK002 — 路径B(走满减)是否明确了"所有商品恢复原价"——即使该商品同时在折扣活动中? [Clarity, Spec §FR-017]
+- [ ] CHK003 — 两条路径都没有命中任何促销活动时(无满减无折扣),路径结果是否一致?是否正确返回原价? [Edge Case, Gap]
+- [ ] CHK004 — 路径A命中折扣但路径B未命中满减门槛时(如订单¥39但满减门槛¥40),路径A是否自动成为最优? [Edge Case, Spec §FR-018]
+- [ ] CHK005 — 路径A和路径B金额完全相等时,默认选哪条?是否有 tie-breaking 规则? [Edge Case, Gap]
+
+## 第二份半价特殊规则
+
+- [ ] CHK006 — "每2件一组,第2件半价"的规则是否定义了同一商品买奇数件的处理(如买3件:第2件半价,第1、3件原价)? [Completeness, Spec §Edge Cases]
+- [ ] CHK007 — 第二份半价商品买多件时,半价优惠金额的计算公式是否明确?如:商品单价¥20,买4件 → ¥20+¥10+¥20+¥10 = ¥60? [Clarity, Spec §Edge Cases]
+- [ ] CHK008 — 第二份半价与路径计算的关系是否明确?它属于路径A(折扣路径)的一部分,还是独立第三条路径? [Ambiguity, Spec §互斥规则]
+- [ ] CHK009 — 同一商品同时在"第二份半价"和"折扣"活动中时,哪条规则优先? [Conflict, Gap]
+
+## 新客立减叠加
+
+- [ ] CHK010 — "新客立减"的定义是否明确?是"该门店首次下单的用户"还是"平台新注册用户"? [Clarity, Spec §FR-004]
+- [ ] CHK011 — 新客立减的计算顺序是否明确?是在促销之后、优惠券之前扣减? [Completeness, Spec §叠加计算顺序]
+- [ ] CHK012 — 算价接口如何判断用户是否为"新客"?是否需要传 userId 由后端查询历史订单? [Completeness, Plan §UserPromotionCalcController]
+- [ ] CHK013 — 新客立减后金额为负数或零时如何处理?是否需要设置最低支付金额? [Edge Case, Gap]
+
+## 优惠券叠加与互斥
+
+- [ ] CHK014 — 互斥券选中后,满减/折扣路径的计算结果如何变化?是否需要在 Response 中返回"如选择此券则促销失效"的提示? [Completeness, Spec §FR-021]
+- [ ] CHK015 — 同享券与满减叠加时,券的门槛是基于"促销后金额"还是"原始金额"?如原始¥45满减后¥33,券要求满30,¥33是否满足? [Clarity, Spec §FR-020]
+- [ ] CHK016 — 商品券作用于指定商品时,是否受该商品已参加折扣活动的影响?如商品已在7折区,再用5折券,是7折基础上再5折(=35折)还是原价直接5折? [Ambiguity, Spec §FR-008]
+- [ ] CHK017 — 用户传了无效/已过期/不满足门槛的 couponId,算价接口是返回错误还是忽略券继续计算? [Edge Case, Gap]
+- [ ] CHK018 — 商品券的 threshold(门槛)是整个订单金额门槛还是该商品本身的金额门槛? [Ambiguity, Spec §promotion_coupon_rule]
+
+## 算价接口输入/输出
+
+- [ ] CHK019 — 算价接口的 items 参数中 price 字段是否应改为后端根据 productId 查询?前端传入价格是否有被篡改风险? [Completeness, Plan §UserPromotionCalcController]
+- [ ] CHK020 — 算价接口返回的 details 数组是否覆盖了所有优惠类型(满减/折扣/第二份半价/新客立减/满减券/商品券)? [Coverage, Plan §算价接口 Response]
+- [ ] CHK021 — forcePath 参数传入非法值(如"C"或空字符串)时接口行为是否定义? [Edge Case, Gap]
+- [ ] CHK022 — 算价接口是否需要返回"可用券列表"供前端展示?还是只计算传入的单张券? [Completeness, Gap]
+
+## 金额精度与边界
+
+- [ ] CHK023 — 优惠金额计算结果的小数精度是否定义?如 7折 × ¥18 = ¥12.6,是保留1位还是2位? [Clarity, Gap]
+- [ ] CHK024 — 优惠后金额出现非整数分(如¥33.333)时的舍入规则是否明确(四舍五入/向上取整/截断)? [Edge Case, Gap]
+- [ ] CHK025 — 多重优惠叠加后最终金额为负数或零时的处理规则是否定义? [Edge Case, Gap]
+- [ ] CHK026 — 满减活动有多个档位(满20减5/满40减12/满60减20)时,订单¥45是命中"满40减12"还是需要找最接近的档位? [Clarity, Spec §Edge Cases]

+ 62 - 0
specs/008-promotion-coupon/checklists/requirements.md

@@ -0,0 +1,62 @@
+# Checklist: 促销 + 优惠券系统 — 整体需求质量
+
+**Purpose**: 实施前预检,确保 spec/plan/tasks 可直接进入编码
+**Created**: 2026-06-01
+**Feature**: 008-promotion-coupon
+**Depth**: Standard | **Actor**: Author (pre-implementation)
+
+---
+
+## 数据模型完整性
+
+- [ ] CHK001 — 6张表的 DDL 是否都有完整字段定义(类型、长度、默认值、NOT NULL 约束)? [Completeness, Spec §Data Model]
+- [ ] CHK002 — promotion_activity_rule 表是否为每种促销类型(1~4)定义了清晰的字段使用映射(哪些字段有值、哪些为 NULL)? [Clarity, Spec §Data Model]
+- [ ] CHK003 — promotion_coupon_rule 表的 is_mutex 字段是否明确标注了"商品券时此字段含义为何"(商品券是否需要互斥属性)? [Ambiguity, Spec §Data Model]
+- [ ] CHK004 — promotion_user_coupon 的 expire_time 计算规则是否明确(receive_time + valid_days 天,含不含当天23:59:59)? [Clarity, Spec §FR-010]
+- [ ] CHK005 — pos_order_promotion 表的 promo_detail(JSON)是否定义了每种促销类型的标准 JSON schema? [Completeness, Gap]
+- [ ] CHK006 — 所有表的主键策略是否统一为 AUTO_INCREMENT?是否有表需要分布式 ID? [Consistency, Spec §Data Model]
+
+## 商家端功能需求
+
+- [ ] CHK007 — 促销活动创建后,状态从"未开始"变为"进行中"的时机是否明确(创建时根据 startTime 判断 vs 定时任务)? [Clarity, Spec §FR-001]
+- [ ] CHK008 — "结束活动"操作是否可逆?结束后的活动能否重新激活? [Gap, Spec §FR-005]
+- [ ] CHK009 — 折扣商品的"折扣区"名称是否需要存储?还是纯前端展示概念? [Clarity, Plan §Key Design Decisions 1]
+- [ ] CHK010 — 优惠券创建时 remain_count 初始化为 total_count 的逻辑是否在 Service 层明确? [Completeness, Tasks T025]
+- [ ] CHK011 — 优惠券"下架"后已领取的券是否仍可使用?此行为是否在 spec 中明确? [Edge Case, Spec §FR-009]
+- [ ] CHK012 — 活动时间范围是否允许跨年或超长周期(如1年)?是否需要限制? [Gap]
+- [ ] CHK013 — 同一门店能否同时创建多个同类型的促销活动(如两个满减活动同时进行)? [Ambiguity, Spec §互斥规则]
+
+## 用户端接口需求
+
+- [ ] CHK014 — 领券接口(FR-012)的并发控制是否定义?多人同时领取最后一张券时的处理策略? [Edge Case, Gap]
+- [ ] CHK015 — 查询门店可领券列表接口(FR-011)是否需要分页? [Completeness, Plan §UserPromotionCouponController]
+- [ ] CHK016 — "我的优惠券"接口(FR-013)是否需要返回券对应的门店名称? [Completeness, Gap]
+- [ ] CHK017 — 领券后有效期计算是否考虑了"当天剩余时间"(如23:50领券,valid_days=1,过期时间是明天23:59还是今天结束)? [Ambiguity, Spec §FR-010]
+- [ ] CHK018 — 用户券状态"已过期"是实时查询判断还是有定时任务批量更新? [Clarity, Gap]
+
+## 互斥规则与叠加
+
+- [ ] CHK019 — 互斥规则"满减/折扣/第二份半价三选一"是否覆盖了门店同时有多个同类型活动的场景(如两个满减活动,选哪个)? [Coverage, Spec §互斥规则]
+- [ ] CHK020 — "新客立减"的"新客"定义是否明确(首次下单?首次在该门店下单?注册后N天内?)? [Clarity, Spec §FR-004]
+- [ ] CHK021 — 同享券和互斥券的叠加逻辑是否在算价接口 Response 中体现(互斥券选中后促销如何变化)? [Consistency, Plan §UserPromotionCalcController]
+- [ ] CHK022 — 商品券作用于指定商品时,如果订单中有多个该商品,是只优惠一个还是全部优惠? [Ambiguity, Spec §FR-008]
+
+## API 设计一致性
+
+- [ ] CHK023 — 商家端 API 的分页参数格式(page/size vs pageNum/pageSize)是否与项目现有 Controller 一致? [Consistency, Plan §ShPromotionActivityController]
+- [ ] CHK024 — 用户端接口路径前缀 `/app/userPromotionCoupon` 是否与项目现有用户端接口路径风格一致? [Consistency, Plan §UserPromotionCouponController]
+- [ ] CHK025 — 所有接口的错误响应格式是否统一(code/msg/data 结构)? [Consistency, Gap]
+- [ ] CHK026 — 算价接口(POST /calculate)是否需要用户登录鉴权?商品价格由前端传入还是后端从数据库查? [Completeness, Plan §UserPromotionCalcController]
+
+## 范围与边界
+
+- [ ] CHK027 — spec 中"不做"的清单(爆品、买赠、店外发券等)是否明确标注为"不做"而非"延后"?避免实施时混淆。 [Clarity, Spec §foodie 范围裁剪]
+- [ ] CHK028 — pos_order 现有优惠字段(mdSalesReduction 等)在本期是否仍需写入?还是只写 pos_order_promotion 明细表? [Ambiguity, Spec §Assumptions]
+- [ ] CHK029 — 旧代码(SalesPromotion、VipQuanyi)的数据库表是否保留?是否有数据迁移需求? [Gap, Spec §旧代码处理]
+- [ ] CHK030 — PromotionUserCoupon 实体在 Phase 2 创建但无 Service,Phase 5.5 才补。是否有 Phase 间数据层断裂风险? [Dependency, Tasks T007 → T039]
+
+## 可测试性
+
+- [ ] CHK031 — Success Criteria(SC-001~005)是否都可通过 Postman + 手动操作验证?还是有需要自动化测试覆盖的? [Measurability, Spec §Success Criteria]
+- [ ] CHK032 — 算价接口的 Response 示例是否覆盖了满减 + 折扣 + 新客立减 + 优惠券全部叠加的场景? [Coverage, Plan §算价接口 Response]
+- [ ] CHK033 — 是否有测试数据准备计划(哪些门店/商品/活动需要预创建)? [Gap]

+ 144 - 15
specs/008-promotion-coupon/plan.md

@@ -5,21 +5,21 @@
 
 
 ## Summary
 ## Summary
 
 
-为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(小程序)的领券、下单优惠计算不在本范围。
+为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(小程序)的领券、下单优惠计算不在本范围。
 
 
-**技术方案**:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),5 张新表 + 2 个商家端 Controller。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型的"分组"概念纯前端实现,提交时展开为扁平的 rules 列表
+**技术方案**:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller(领券/查券 + 算价计算)。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型采用**逐商品设折扣**方式(参考美团),每个商品独立设置折扣率,不使用折扣区分组
 
 
 ## Technical Context
 ## Technical Context
 
 
 **Language/Version**: Java 17 (Spring Boot 3.x, MyBatis-Plus)
 **Language/Version**: Java 17 (Spring Boot 3.x, MyBatis-Plus)
 **Primary Dependencies**: Spring Boot, MyBatis-Plus, Vue.js 2.6, Element UI 2.15, vue-i18n
 **Primary Dependencies**: Spring Boot, MyBatis-Plus, Vue.js 2.6, Element UI 2.15, vue-i18n
-**Storage**: MySQL (5 张新表: promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon)
+**Storage**: MySQL (6 张新表: promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_order_promotion)
 **Testing**: 手动测试 (Postman + 前端页面验证)
 **Testing**: 手动测试 (Postman + 前端页面验证)
 **Target Platform**: 商家 PC 端管理后台 (foodie-store)
 **Target Platform**: 商家 PC 端管理后台 (foodie-store)
 **Project Type**: Web 应用 (Java 后端 + Vue 前端)
 **Project Type**: Web 应用 (Java 后端 + Vue 前端)
 **Performance Goals**: 无特殊性能要求,标准 CRUD 操作
 **Performance Goals**: 无特殊性能要求,标准 CRUD 操作
 **Constraints**: 旧代码不动,新表用 promotion_ 前缀,SQL 写入 updatesql/sql.md 手动执行,前端文件 CRLF 换行
 **Constraints**: 旧代码不动,新表用 promotion_ 前缀,SQL 写入 updatesql/sql.md 手动执行,前端文件 CRLF 换行
-**Scale/Scope**: 商家端 2 个新页面 + 后端 5 个实体 + 2 个 Controller
+**Scale/Scope**: 商家端 2 个新页面 + 后端 6 个实体 + 4 个 Controller (2 商家端 + 2 用户端)
 
 
 ## Constitution Check
 ## Constitution Check
 
 
@@ -77,6 +77,10 @@ ruoyi-admin/src/main/java/com/ruoyi/app/mendian/
 ├── ShPromotionActivityController.java    # 商家促销活动 API
 ├── ShPromotionActivityController.java    # 商家促销活动 API
 └── ShPromotionCouponController.java      # 商家优惠券 API
 └── ShPromotionCouponController.java      # 商家优惠券 API
 
 
+ruoyi-admin/src/main/java/com/ruoyi/app/user/
+├── UserPromotionCouponController.java    # 用户端优惠券接口(领券、查券)
+└── UserPromotionCalcController.java      # 用户端算价接口(计算优惠)
+
 updatesql/
 updatesql/
 └── sql.md                                # SQL 迁移脚本(追加建表语句)
 └── sql.md                                # SQL 迁移脚本(追加建表语句)
 
 
@@ -103,21 +107,42 @@ src/
 
 
 ## Key Design Decisions
 ## Key Design Decisions
 
 
-### 1. 折扣类型的"分组"概念
+### 1. 折扣类型采用逐商品设折扣(参考美团)
+
+折扣商品(type=2)采用**逐商品设折扣**方式(参考美团/饿了么),不使用「折扣区/折扣档位」分组概念。
 
 
-折扣商品(type=2)在前端用"折扣区"的分组概念交互(如"4折区"、"7折区"),但后端 `promotion_activity_rule` 表不存储分组信息——每条规则直接记录 `(productId, discountRate)`。
+商家操作流程:
+1. 点击「添加商品」打开商品选择弹窗,勾选参与折扣的商品
+2. 选中后每个商品在列表中独立一行,可分别设置折扣率(10%~99%,步长0.1,-/+按钮或直接输入)
+3. 折后价实时计算显示
+4. 同一商品只能添加一次
 
 
-前端提交时将分组展开为扁平的 rules 列表:
+前端提交的 rules 即为扁平列表,与后端 `promotion_activity_rule` 表结构一一对应
 ```
 ```
-折扣组 "4折区" (rate=0.4) 包含 [可乐, 雪碧]
-折扣组 "7折区" (rate=0.7) 包含 [宫保鸡丁]
-→ 提交为 rules: [
-    {productId: 可乐ID, discountRate: 0.4},
-    {productId: 雪碧ID, discountRate: 0.4},
-    {productId: 宫保鸡丁ID, discountRate: 0.7}
+rules: [
+    {productId: 可乐ID, discountRate: 0.5},
+    {productId: 宫保鸡丁ID, discountRate: 0.7},
+    {productId: 麻婆豆腐ID, discountRate: 0.8}
   ]
   ]
 ```
 ```
 
 
+**UI 示意**:
+```
+活动名称: [超值特惠]        活动时间: [开始] ~ [结束]
+
+商品列表:
+┌──────────────┬────────┬──────────────┬──────────┬────────┐
+│ 商品名称      │ 原价    │ 折扣率        │ 折后价    │ 操作    │
+├──────────────┼────────┼──────────────┼──────────┼────────┤
+│ 可乐          │ 6      │ 50% [-][+]   │ 3.0      │ 移除   │
+│ 宫保鸡丁      │ 20     │ 70% [-][+]   │ 14.0     │ 移除   │
+│ 麻婆豆腐      │ 18     │ 80% [-][+]   │ 14.4     │ 移除   │
+└──────────────┴────────┴──────────────┴──────────┴────────┘
+[+ 添加商品]
+```
+
+**调研记录**:`docs/research-meituan-discount-product.md`
+
 ### 2. 活动状态管理
 ### 2. 活动状态管理
 
 
 活动状态使用显式字段(0=未开始, 1=进行中, 2=已结束),在创建时根据 startTime 自动设置。不使用定时任务自动更新状态(简化实现),列表查询时按 status 字段过滤即可。
 活动状态使用显式字段(0=未开始, 1=进行中, 2=已结束),在创建时根据 startTime 自动设置。不使用定时任务自动更新状态(简化实现),列表查询时按 status 字段过滤即可。
@@ -141,6 +166,8 @@ src/
 | GET | `/system/shPromotionActivity/list` | 分页列表 | params: page, size, storeId, type?, status? | `{ code:200, data: { records:[], total, current, size } }` |
 | GET | `/system/shPromotionActivity/list` | 分页列表 | params: page, size, storeId, type?, status? | `{ code:200, data: { records:[], total, current, size } }` |
 | GET | `/system/shPromotionActivity/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, type, name, status, startTime, endTime, rules: [...] } }` |
 | GET | `/system/shPromotionActivity/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, type, name, status, startTime, endTime, rules: [...] } }` |
 | POST | `/system/shPromotionActivity` | 创建活动 | body: `{ storeId, type, name, startTime, endTime, rules: [...] }` | `{ code:200, msg:"操作成功" }` |
 | POST | `/system/shPromotionActivity` | 创建活动 | body: `{ storeId, type, name, startTime, endTime, rules: [...] }` | `{ code:200, msg:"操作成功" }` |
+| PUT | `/system/shPromotionActivity` | 修改活动(仅未开始) | body: `{ id, storeId, type, name, startTime, endTime, rules: [...] }` | `{ code:200, msg:"操作成功" }` |
+| DELETE | `/system/shPromotionActivity/{id}` | 删除活动(仅未开始) | path: id | `{ code:200, msg:"操作成功" }` |
 | PUT | `/system/shPromotionActivity/{id}/end` | 结束活动 | path: id | `{ code:200, msg:"操作成功" }` |
 | PUT | `/system/shPromotionActivity/{id}/end` | 结束活动 | path: id | `{ code:200, msg:"操作成功" }` |
 
 
 ### ShPromotionCouponController
 ### ShPromotionCouponController
@@ -150,8 +177,110 @@ src/
 | GET | `/system/shPromotionCoupon/list` | 分页列表 | params: page, size, storeId, couponType?, status? | `{ code:200, data: { records:[], total } }` |
 | GET | `/system/shPromotionCoupon/list` | 分页列表 | params: page, size, storeId, couponType?, status? | `{ code:200, data: { records:[], total } }` |
 | GET | `/system/shPromotionCoupon/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, name, couponType, totalCount, ..., rule: {...} } }` |
 | GET | `/system/shPromotionCoupon/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, name, couponType, totalCount, ..., rule: {...} } }` |
 | POST | `/system/shPromotionCoupon` | 创建券 | body: `{ storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` | `{ code:200, msg:"操作成功" }` |
 | POST | `/system/shPromotionCoupon` | 创建券 | body: `{ storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` | `{ code:200, msg:"操作成功" }` |
+| PUT | `/system/shPromotionCoupon` | 修改券(未开始/进行中) | body: `{ id, storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` | `{ code:200, msg:"操作成功" }` |
+| DELETE | `/system/shPromotionCoupon/{id}` | 删除券(仅未开始) | path: id | `{ code:200, msg:"操作成功" }` |
 | PUT | `/system/shPromotionCoupon/{id}/offShelf` | 下架券 | path: id | `{ code:200, msg:"操作成功" }` |
 | PUT | `/system/shPromotionCoupon/{id}/offShelf` | 下架券 | path: id | `{ code:200, msg:"操作成功" }` |
 
 
+### UserPromotionCouponController(用户端 — 领券/查券)
+
+| Method | Path | Description | Request | Response |
+|--------|------|-------------|---------|----------|
+| GET | `/app/userPromotionCoupon/storeCoupons` | 查询门店可领优惠券列表 | params: storeId | `{ code:200, data: [{ id, name, couponType, threshold, amount, discountRate, isMutex, totalCount, remainCount, hasReceived }] }` |
+| POST | `/app/userPromotionCoupon/receive` | 领取优惠券 | body: `{ batchId, storeId }` | `{ code:200, data: { userCouponId }, msg:"领取成功" }` |
+| GET | `/app/userPromotionCoupon/myCoupons` | 查询用户优惠券列表 | params: status?(0=未使用/1=已使用/2=已过期), storeId? | `{ code:200, data: [{ id, batchId, storeId, name, couponType, threshold, amount, discountRate, isMutex, status, expireTime }] }` |
+
+### UserPromotionCalcController(用户端 — 算价计算)
+
+| Method | Path | Description | Request | Response |
+|--------|------|-------------|---------|----------|
+| POST | `/app/userPromotionCalc/calculate` | 计算订单优惠 | body: `{ storeId, items:[{productId, quantity}], couponId?, forcePath? }` | 见下方 Response 示例 |
+
+**算价接口参数说明**:
+- `items` 中只传 `productId` 和 `quantity`,**不传 price**。后端根据 productId 从数据库查询商品实际价格,防止前端篡改
+- `couponId`: 可选,传入用户券ID则计算使用该券后的价格;不传则不使用券
+- `forcePath`: 可选,"A"=强制走折扣路径,"B"=强制走满减路径,不传或非法值=自动选最优
+- 接口需通过登录态获取 userId,用于判断新客立减资格和查询用户可用券
+
+**算价接口 Response 示例 1(满减+优惠券+新客立减)**:
+```json
+{
+  "code": 200,
+  "data": {
+    "originalAmount": 45.00,
+    "pathA": {
+      "label": "折扣",
+      "items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 14.00 }],
+      "subtotal": 39.00,
+      "promotionReduce": 6.00
+    },
+    "pathB": {
+      "label": "满减",
+      "items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 20.00 }],
+      "subtotal": 33.00,
+      "promotionReduce": 12.00,
+      "matchedRule": { "threshold": 40, "reduce": 12 }
+    },
+    "optimalPath": "B",
+    "couponReduce": 5.00,
+    "finalAmount": 28.00,
+    "details": [
+      { "type": "promotion", "subType": 1, "name": "午市满减(满40减12)", "reduce": 12.00 },
+      { "type": "coupon", "name": "满30减5(同享券)", "reduce": 5.00 }
+    ],
+    "availableCoupons": [
+      { "id": 10, "name": "满30减5(同享券)", "couponType": 1, "isMutex": 0, "threshold": 30.00, "amount": 5.00 },
+      { "id": 11, "name": "满30减5(互斥券)", "couponType": 1, "isMutex": 1, "threshold": 30.00, "amount": 5.00 }
+    ]
+  }
+}
+```
+
+**算价接口 Response 示例 2(互斥券场景)**:
+```json
+{
+  "code": 200,
+  "data": {
+    "originalAmount": 45.00,
+    "pathA": { "label": "折扣", "subtotal": 39.00, "promotionReduce": 6.00 },
+    "pathB": { "label": "满减", "subtotal": 33.00, "promotionReduce": 12.00 },
+    "optimalPath": "B",
+    "couponId": 11,
+    "couponName": "满30减5(互斥券)",
+    "couponConflict": true,
+    "conflictNote": "互斥券不可与满减叠加,选择此券将取消满减优惠",
+    "finalAmount": 40.00,
+    "details": [
+      { "type": "coupon", "name": "满30减5(互斥券)", "reduce": 5.00 }
+    ]
+  }
+}
+```
+
+**算价接口 Response 示例 3(第二份半价场景)**:
+```json
+{
+  "code": 200,
+  "data": {
+    "originalAmount": 60.00,
+    "pathA": {
+      "label": "第二份半价",
+      "items": [
+        { "productId": 3, "originalPrice": 20.00, "finalPrice": 20.00, "discountNote": "第1件原价" },
+        { "productId": 3, "originalPrice": 20.00, "finalPrice": 10.00, "discountNote": "第2件半价" }
+      ],
+      "subtotal": 50.00,
+      "promotionReduce": 10.00
+    },
+    "pathB": { "label": "无满减活动", "subtotal": 60.00, "promotionReduce": 0 },
+    "optimalPath": "A",
+    "finalAmount": 50.00,
+    "details": [
+      { "type": "promotion", "subType": 3, "name": "第二份半价(可乐)", "reduce": 10.00 }
+    ]
+  }
+}
+```
+
 ### Request Body Examples
 ### Request Body Examples
 
 
 **创建满减活动**:
 **创建满减活动**:
@@ -206,7 +335,7 @@ src/
 
 
 ## Data Model
 ## Data Model
 
 
-参见 spec.md 的 Data Model 章节,5 张表完整 DDL 已在 spec 中定义。
+参见 spec.md 的 Data Model 章节,6 张表完整 DDL 已在 spec 中定义。
 
 
 表关系:
 表关系:
 ```
 ```
@@ -219,5 +348,5 @@ promotion_coupon_batch 1 → N promotion_user_coupon
 
 
 | Violation | Why Needed | Simpler Alternative Rejected Because |
 | Violation | Why Needed | Simpler Alternative Rejected Because |
 |-----------|------------|-------------------------------------|
 |-----------|------------|-------------------------------------|
-| 折扣类型前端分组概念 | 商家需要按折扣率分组管理商品,类似美团交互 | 直接输入每条规则太繁琐,不符合商家操作习惯 |
+| 折扣类型前端分组概念(已废弃→改为逐商品设折扣) | 原设计按折扣率分组管理商品,参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担,美团实际不使用分组 |
 | 4种活动类型共用一个创建对话框 | 商家从同一个入口创建不同类型的活动 | 每种类型独立页面会导致页面冗余 |
 | 4种活动类型共用一个创建对话框 | 商家从同一个入口创建不同类型的活动 | 每种类型独立页面会导致页面冗余 |

+ 104 - 39
specs/008-promotion-coupon/spec.md

@@ -39,10 +39,10 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 ### 工作范围
 ### 工作范围
 
 
 只有两部分:
 只有两部分:
-1. **后端 API**(Java/Spring Boot)— 新建 Entity、Mapper、Service、Controller
+1. **后端 API**(Java/Spring Boot)— 新建 Entity、Mapper、Service、Controller,包含商家端管理接口和用户端接口(领券、优惠券查询、算价计算)
 2. **商家 PC 端前端**(foodie-store Vue.js)— 促销管理页面 + 优惠券管理页面
 2. **商家 PC 端前端**(foodie-store Vue.js)— 促销管理页面 + 优惠券管理页面
 
 
-用户端(小程序)的领券、下单优惠计算不在本 spec 范围内,后续单独做
+用户端(小程序)的前端界面不在本 spec 范围内,后续单独做。但后端提供的用户端接口(FR-011~022)在本期实施
 
 
 ### 美团商家营销活动分类
 ### 美团商家营销活动分类
 
 
@@ -55,7 +55,7 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 
 
 本需求只实现以下功能:
 本需求只实现以下功能:
 
 
-**促销(4种)**:满减、折扣商品(分组折扣)、第二份半价、新客立减
+**促销(4种)**:满减、折扣商品(逐商品设折扣)、第二份半价、新客立减
 
 
 **不做**:爆品、买赠、减配送费、满减运费、好友助力等
 **不做**:爆品、买赠、减配送费、满减运费、好友助力等
 
 
@@ -77,47 +77,68 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 参考美团做法,用户下单时系统自动计算两条路径,选最优惠的:
 参考美团做法,用户下单时系统自动计算两条路径,选最优惠的:
 
 
 - **满减、折扣商品、第二份半价 → 三选一**,系统算两遍自动选最优
 - **满减、折扣商品、第二份半价 → 三选一**,系统算两遍自动选最优
-  - 路径A(走折扣):折扣商品按折扣价,非折扣商品原价
-  - 路径B(走满减):所有商品恢复原价,再减满减金额
+  - 路径A(走折扣/第二份半价):系统分别计算「折扣」和「第二份半价」各一遍,取两者中更优的作为路径A结果。折扣商品按折扣价,第二份半价商品按半价规则,非活动商品原价
+  - 路径B(走满减):所有商品恢复原价,匹配满减最高可达档位,再减满减金额
 - **新客立减** → 可与以上任意一种叠加
 - **新客立减** → 可与以上任意一种叠加
 - **互斥在下单时判断**,不限制商家创建活动
 - **互斥在下单时判断**,不限制商家创建活动
 
 
 ### 叠加计算顺序
 ### 叠加计算顺序
 
 
 ```
 ```
-商品原价 → 促销(满减/折扣/第二份半价,三选一)→ 商家满减券 → 平台券 = 实付金额
+商品原价 → 促销(满减/折扣/第二份半价,三选一)→ 商家满减券 = 实付金额
 商品券单独作用在指定商品上,不与满减券叠加
 商品券单独作用在指定商品上,不与满减券叠加
-一个订单最多:1个促销 + 1张满减券 + 1张平台券
+一个订单最多:1个促销 + 1张满减券
 ```
 ```
 
 
+**同享券门槛基准**:满减券的 threshold(使用门槛)基于促销后的金额判断(如促销后¥33,券要求满30,¥33≥¥30 可用)。
+
+**商品券与促销互斥规则**:商品券对应的商品如果正在参加折扣或第二份半价活动,该商品券不可用于该商品(放入不可用券列表),避免折上折。
+
 ## User Scenarios & Testing
 ## User Scenarios & Testing
 
 
 ### User Story 1 - 商家创建促销活动 (Priority: P1)
 ### User Story 1 - 商家创建促销活动 (Priority: P1)
 
 
-商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。
+商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。未开始的活动可以修改和删除,参考美团商家端交互。
 
 
 **Why this priority**: 促销是商家最核心的营销需求,没有促销就没有优惠,后续优惠券也无法叠加使用。
 **Why this priority**: 促销是商家最核心的营销需求,没有促销就没有优惠,后续优惠券也无法叠加使用。
 
 
 **Independent Test**: 商家创建一个满减活动(满20减5),用户下单满20元后自动减5元,订单金额正确。
 **Independent Test**: 商家创建一个满减活动(满20减5),用户下单满20元后自动减5元,订单金额正确。
 
 
+**操作权限矩阵(参考美团)**:
+
+| 活动状态 | 编辑 | 删除 | 结束 |
+|----------|------|------|------|
+| 未开始(0) | ✅ | ✅ | ❌ |
+| 进行中(1) | ❌ | ❌ | ✅ |
+| 已结束(2) | ❌ | ❌ | ❌ |
+
 **Acceptance Scenarios**:
 **Acceptance Scenarios**:
 
 
 1. **Given** 商家未创建任何促销活动, **When** 商家创建满减活动(满20减5 / 满40减12), **Then** 活动列表显示进行中,用户端店铺显示满减标签
 1. **Given** 商家未创建任何促销活动, **When** 商家创建满减活动(满20减5 / 满40减12), **Then** 活动列表显示进行中,用户端店铺显示满减标签
 2. **Given** 商家已创建折扣活动(宫保鸡丁7折、麻婆豆腐8折), **When** 用户下单宫保鸡丁(¥20) + 麻婆豆腐(¥18), **Then** 折扣价 ¥14 + ¥14.4 = ¥28.4
 2. **Given** 商家已创建折扣活动(宫保鸡丁7折、麻婆豆腐8折), **When** 用户下单宫保鸡丁(¥20) + 麻婆豆腐(¥18), **Then** 折扣价 ¥14 + ¥14.4 = ¥28.4
 3. **Given** 商家同时有满减(满40减12)和折扣(宫保鸡丁7折¥14), **When** 用户下单宫保鸡丁(¥20) + 鸡腿饭(¥25) = ¥45, **Then** 系统算两遍:走折扣¥39 vs 走满减¥33,自动选满减
 3. **Given** 商家同时有满减(满40减12)和折扣(宫保鸡丁7折¥14), **When** 用户下单宫保鸡丁(¥20) + 鸡腿饭(¥25) = ¥45, **Then** 系统算两遍:走折扣¥39 vs 走满减¥33,自动选满减
 4. **Given** 商家创建新客立减3元 + 满减(满40减12), **When** 新用户下单¥45, **Then** 先减满减12 = ¥33,再减新客立减3 = ¥30
 4. **Given** 商家创建新客立减3元 + 满减(满40减12), **When** 新用户下单¥45, **Then** 先减满减12 = ¥33,再减新客立减3 = ¥30
-5. **Given** 折扣活动采用分组折扣, **When** 商家先建"4折区"和"7折区",把可乐拖入4折区、宫保鸡丁拖入7折区, **Then** 可乐¥6×0.4=¥2.4,宫保鸡丁¥20×0.7=¥14
+5. **Given** 折扣活动采用逐商品设折扣, **When** 商家选择可乐设5折、宫保鸡丁设7折、麻婆豆腐设8折, **Then** 可乐¥6×0.5=¥3,宫保鸡丁¥20×0.7=¥14,麻婆豆腐¥18×0.8=¥14.4
 
 
 ---
 ---
 
 
 ### User Story 2 - 商家创建优惠券 (Priority: P2)
 ### User Story 2 - 商家创建优惠券 (Priority: P2)
 
 
-商家创建优惠券(满减券/商品券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。
+商家创建优惠券(满减券/商品券/免配送费券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。未开始和进行中的优惠券可以修改,未开始的可以删除。
 
 
 **Why this priority**: 优惠券在促销基础上进一步促进复购和转化,但依赖促销系统先完成。
 **Why this priority**: 优惠券在促销基础上进一步促进复购和转化,但依赖促销系统先完成。
 
 
 **Independent Test**: 商家创建"满30减5"优惠券100张,用户进店领取,下单满30元时选择使用,实付减5元。
 **Independent Test**: 商家创建"满30减5"优惠券100张,用户进店领取,下单满30元时选择使用,实付减5元。
 
 
+**操作权限矩阵(参考美团)**:
+
+| 券状态 | 编辑 | 删除 | 下架 |
+|--------|------|------|------|
+| 未开始(0) | ✅ | ✅ | ❌ |
+| 进行中(1) | ✅ | ❌ | ✅ |
+| 已结束(2) | ❌ | ❌ | ❌ |
+| 已下架(3) | ❌ | ❌ | ❌ |
+
 **Acceptance Scenarios**:
 **Acceptance Scenarios**:
 
 
 1. **Given** 商家创建满减券(同享券,满30减5,100张), **When** 用户进店看到领券区并领取, **Then** 用户"我的优惠券"列表显示该券,状态为未使用
 1. **Given** 商家创建满减券(同享券,满30减5,100张), **When** 用户进店看到领券区并领取, **Then** 用户"我的优惠券"列表显示该券,状态为未使用
@@ -151,8 +172,46 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - 用户领券后商家修改/下架了该券批次 → 已领取的券仍可使用(按领取时的规则),但不能再领取新券
 - 用户领券后商家修改/下架了该券批次 → 已领取的券仍可使用(按领取时的规则),但不能再领取新券
 - 满减活动设了3个档位,订单金额正好在两个档位之间 → 命中低档位(如¥39命中满20减5而非满40减12)
 - 满减活动设了3个档位,订单金额正好在两个档位之间 → 命中低档位(如¥39命中满20减5而非满40减12)
 - 第二份半价商品用户只买了1件 → 不享受优惠,按原价
 - 第二份半价商品用户只买了1件 → 不享受优惠,按原价
+- 第二份半价商品用户买了多件 → 每2件为一组,每组第2件半价(如买3件:第1、3件全价,第2件半价;买4件:第1、3件全价,第2、4件半价)
 - 商家结束促销活动时,正在进行中的订单 → 不影响已下单的订单,只影响新订单
 - 商家结束促销活动时,正在进行中的订单 → 不影响已下单的订单,只影响新订单
 - 优惠券过期 → 过期券自动标记为已过期,不可使用
 - 优惠券过期 → 过期券自动标记为已过期,不可使用
+- 折扣活动中同一商品重复添加 → 前端限制同一商品只能添加一次,重复选择时提示并忽略
+- 多用户同时领取同一优惠券(库存仅剩1张)→ 数据库原子扣减 `WHERE remain_count > 0`,只有一人成功,其余返回"已领完"
+
+### 算价精度规则
+
+- 所有金额计算使用 `BigDecimal`,中间过程不截断,最终结果 `ROUND_HALF_UP`(四舍五入)保留2位小数
+- 最终实付金额最低为 ¥0.01,不出现零或负数
+- 两条路径金额完全相等时,默认选路径B(满减路径),因满减对商家毛利更优
+- 无促销活动时两条路径结果相同,直接返回原价
+- 优惠券有效期计算:`expire_time = 领取日期 + valid_days 天后的 23:59:59`(如6月1日领券,valid_days=7,则6月8日 23:59:59 过期)
+- 用户券过期判断:查询时实时判断 `expire_time < NOW()` 则标记为已过期(status→2),不依赖定时任务
+- 同一门店同一促销类型只允许一个进行中的活动,创建时校验
+- 结束活动不可逆,已结束活动不可重新激活
+- 商品价格由后端根据 productId 从数据库查询,不信任前端传入的价格
+
+## Clarifications
+
+### Session 2026-06-01
+
+- Q: 折扣活动中同一商品重复添加怎么处理? → A: 前端限制同一商品只能添加一次,重复选择时提示并忽略(改为逐商品设折扣,不再有折扣区分组概念)
+- Q: 折扣商品采用「折扣区分组」还是「逐商品设折扣」? → A: 改为逐商品设折扣(参考美团),每个商品独立设置折扣率,不再使用折扣区/折扣档位分组。调研记录见 `docs/research-meituan-discount-product.md`
+- Q: 新客立减的"新客"定义? → A: 该门店首次下单的用户(该门店无已完成订单记录)
+- Q: 算价接口前端传入价格有篡改风险? → A: 算价接口只传productId+quantity,后端根据productId查实际价格
+- Q: 第二份半价属于路径A还是独立路径? → A: 作为路径A的变体,系统分别算折扣和第二份半价,取更优作为路径A结果
+- Q: 同享券门槛基于促销前还是促销后金额? → A: 基于促销后金额
+- Q: 商品在折扣活动中,商品券能否用于该商品? → A: 不可用,放入不可用券列表
+- Q: 金额精度和舍入规则? → A: BigDecimal计算,ROUND_HALF_UP保留2位小数,最低¥0.01
+- Q: 免配送费券如何获取配送费金额? → A: 算价接口新增可选参数deliveryFee,前端传入当前配送费,后端计算min(coupon.amount, deliveryFee)作为减免额
+- Q: 删除促销活动是物理删除还是软删除? → A: 物理删除(DELETE),因为未开始活动不会有订单引用,安全且简单
+- Q: 编辑进行中的优惠券时,已领取用户的券规则是否跟随变更? → A: 只影响新领取的券,已领取的券保持原规则不变(快照机制)
+- Q: 免配送费券在叠加计算顺序中的位置? → A: 在促销+满减券之后独立叠加:实付 = 促销后金额 - 券减免 - min(配送费券.amount, deliveryFee)
+- Q: 优惠券删除条件? → A: 仅当 status=0 且 receivedCount=0 时允许删除(双重校验,防御性编程)
+
+### Session 2026-06-02
+
+- Q: 折扣率允许范围和 UI 控件? → A: 1折~9.9折(10%~99%),步长 0.1,用 -/+ 按钮或直接输入。超出范围前端阻止
+- Q: 用户领券并发超发怎么防止? → A: 数据库原子扣减:`UPDATE remain_count = remain_count - 1, received_count = received_count + 1 WHERE id = #{id} AND remain_count > 0`,affected rows=0 则返回"已领完"
 
 
 ## Requirements
 ## Requirements
 
 
@@ -161,46 +220,51 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 #### 促销活动管理(商家端)
 #### 促销活动管理(商家端)
 
 
 - **FR-001**: 商家 MUST 能创建满减活动,支持设置多个档位(如满20减5 / 满40减12 / 满60减20)
 - **FR-001**: 商家 MUST 能创建满减活动,支持设置多个档位(如满20减5 / 满40减12 / 满60减20)
-- **FR-002**: 商家 MUST 能创建折扣商品活动,采用分组折扣方式:先建折扣档位(如4折区/7折区/8折区),再为每个档位分配商品
+- **FR-002**: 商家 MUST 能创建折扣商品活动,采用**逐商品设折扣**方式(参考美团):从商品列表勾选参与的商品,每个商品单独设置折扣率。一个活动内不同商品可以有不同折扣率(如宫保鸡丁7折、麻婆豆腐8折、可乐5折)。不使用「折扣区/折扣档位」分组概念。折扣率范围:10%~99%(即1折~9.9折),步长 0.1,前端用 -/+ 按钮或直接输入
 - **FR-003**: 商家 MUST 能创建第二份半价活动,从商品列表勾选参加的商品
 - **FR-003**: 商家 MUST 能创建第二份半价活动,从商品列表勾选参加的商品
-- **FR-004**: 商家 MUST 能创建新客立减活动,设置固定减免金额
+- **FR-004**: 商家 MUST 能创建新客立减活动,设置固定减免金额。「新客」定义为:该门店首次下单的用户(该门店无已完成订单记录)
 - **FR-005**: 商家 MUST 能查看活动列表(进行中/未开始/已结束),能结束进行中的活动
 - **FR-005**: 商家 MUST 能查看活动列表(进行中/未开始/已结束),能结束进行中的活动
+- **FR-005a**: 商家 MUST 能修改未开始的活动(编辑活动名称、时间、规则),复用创建对话框回填数据
+- **FR-005b**: 商家 MUST 能删除未开始的活动(物理删除活动及关联规则),删除前需二次确认
 - **FR-006**: 系统 MUST 展示互斥规则提示:满减、折扣、第二份半价三选一,新客立减可叠加
 - **FR-006**: 系统 MUST 展示互斥规则提示:满减、折扣、第二份半价三选一,新客立减可叠加
 
 
 #### 优惠券管理(商家端)
 #### 优惠券管理(商家端)
 
 
 - **FR-007**: 商家 MUST 能创建满减券,设置同享/互斥属性、使用门槛、减免金额、发放总量、有效天数
 - **FR-007**: 商家 MUST 能创建满减券,设置同享/互斥属性、使用门槛、减免金额、发放总量、有效天数
-- **FR-008**: 商家 MUST 能创建商品券(折扣券/抵用券),关联指定商品,设置折扣率
-- **FR-009**: 商家 MUST 能查看优惠券列表,展示领取/使用情况、库存
+- **FR-008**: 商家 MUST 能创建商品券(折扣券/抵用券),关联指定商品,设置折扣率。商品券优惠作用于该商品的每一件(如买3个宫保鸡丁,5折券则全部按5折计算)
+- **FR-008a**: 商家 MUST 能创建免配送费券,设置使用门槛(满X元可用)、减免金额(固定减免配送费Y元,减免金额≥实际配送费时等同于免配送费)、同享/互斥属性、发放总量、有效天数。算价接口新增可选参数 `deliveryFee`,前端传入当前配送费,后端计算 `min(coupon.amount, deliveryFee)` 作为实际减免额
+- **FR-009**: 商家 MUST 能查看优惠券列表,展示领取/使用情况、库存,能下架进行中的优惠券
+- **FR-009a**: 商家 MUST 能修改未开始/进行中的优惠券(编辑名称、时间、规则等),复用创建对话框回填数据
+- **FR-009b**: 商家 MUST 能删除未开始的优惠券(物理删除券批次+规则),仅当 status=0 且 receivedCount=0 时允许删除,删除前需二次确认
 - **FR-010**: 优惠券发放方式只有一种:店内领券(用户进店时在领券区领取)
 - **FR-010**: 优惠券发放方式只有一种:店内领券(用户进店时在领券区领取)
 
 
-#### 用户端
+#### 用户端接口(后端API本期实施,前端界面[延后]由小程序实施)
 
 
-- **FR-011**: 用户 MUST 能在店铺首页看到领券区,展示可领取的优惠券
-- **FR-012**: 用户 MUST 能领取优惠券,每人每批次限领1张
-- **FR-013**: 用户 MUST 能在"我的优惠券"中查看已领取的券(未使用/已使用/已过期)
-- **FR-014**: 用户 MUST 能在结算页看到促销优惠明细(自动选最优方案)
-- **FR-015**: 用户 MUST 能在结算页选择使用/不使用优惠券
-- **FR-016**: 系统 MUST 在结算页自动计算满减和折扣两条路径,默认选最优
+- **FR-011**: 用户 MUST 能在店铺首页看到领券区 → 后端提供「查询门店可领优惠券列表」接口
+- **FR-012**: 用户 MUST 能领取优惠券,每人每批次限领1张 → 后端提供「领取优惠券」接口
+- **FR-013**: 用户 MUST 能在"我的优惠券"中查看已领取的券 → 后端提供「查询用户优惠券列表」接口
+- **FR-014**: 用户 MUST 能在结算页看到促销优惠明细 → 后端提供「计算订单优惠」接口
+- **FR-015**: 用户 MUST 能在结算页选择使用/不使用优惠券 → 由算价接口参数控制
+- **FR-016**: 系统 MUST 在结算页自动计算满减和折扣两条路径,默认选最优 → 算价接口返回两条路径对比
 
 
-#### 算价逻辑
+#### 算价逻辑(后端Service本期实施,前端展示[延后]由小程序实施)
 
 
 - **FR-017**: 下单时系统 MUST 同时计算两条路径:
 - **FR-017**: 下单时系统 MUST 同时计算两条路径:
   - 路径A(走折扣):折扣商品按折扣价,非折扣商品原价,再扣券
   - 路径A(走折扣):折扣商品按折扣价,非折扣商品原价,再扣券
   - 路径B(走满减):所有商品恢复原价,扣满减,再扣券
   - 路径B(走满减):所有商品恢复原价,扣满减,再扣券
 - **FR-018**: 系统 MUST 默认选择实付金额更低的路径
 - **FR-018**: 系统 MUST 默认选择实付金额更低的路径
-- **FR-019**: 用户 MUST 能在结算页手动切换路径
-- **FR-020**: 同享券 MUST 能与满减/折扣叠加使用
+- **FR-019**: 用户 MUST 能在结算页手动切换路径 → 算价接口支持指定路径参数
+- **FR-020**: 同享券 MUST 能与满减/折扣叠加使用。满减券的 threshold 基于促销后金额判断
 - **FR-021**: 互斥券 MUST 不能与满减/折扣/第二份半价叠加
 - **FR-021**: 互斥券 MUST 不能与满减/折扣/第二份半价叠加
-- **FR-022**: 一个订单最多使用 1个促销 + 1张满减券 + 1张平台券
+- **FR-022**: 一个订单最多使用 1个促销 + 1张满减券。商品券对应的商品如正在参加折扣/第二份半价活动,该券不可用于该商品
 
 
 ### Key Entities
 ### Key Entities
 
 
-- **促销活动 (store_promotion)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围
-- **促销规则 (store_promotion_rule)**: 促销活动的具体规则数据,一个活动对应多条规则(满减档位/折扣商品/第二份半价商品)
-- **券批次 (store_coupon_batch)**: 优惠券模板,包含券名称、类型、库存、有效期
-- **券规则 (store_coupon_rule)**: 优惠券的使用规则,包含门槛、金额、折扣率、同享/互斥属性
-- **用户券 (user_coupon)**: 用户领取的券实例,绑定用户和门店,记录状态和使用情况
+- **促销活动 (promotion_activity)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围
+- **促销规则 (promotion_activity_rule)**: 促销活动的具体规则数据,一个活动对应多条规则(满减档位/折扣商品/第二份半价商品)
+- **券批次 (promotion_coupon_batch)**: 优惠券模板,包含券名称、类型、库存、有效期
+- **券规则 (promotion_coupon_rule)**: 优惠券的使用规则,包含门槛、金额、折扣率、同享/互斥属性
+- **用户券 (promotion_user_coupon)**: 用户领取的券实例,绑定用户和门店,记录状态和使用情况
 
 
 ## Data Model
 ## Data Model
 
 
@@ -245,7 +309,7 @@ CREATE TABLE promotion_coupon_batch (
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   store_id          BIGINT       NOT NULL COMMENT '门店ID',
   store_id          BIGINT       NOT NULL COMMENT '门店ID',
   name              VARCHAR(100) NOT NULL COMMENT '券名称',
   name              VARCHAR(100) NOT NULL COMMENT '券名称',
-  coupon_type       TINYINT      NOT NULL COMMENT '1=满减券 2=商品券',
+  coupon_type       TINYINT      NOT NULL COMMENT '1=满减券 2=商品券 3=免配送费券',
   total_count       INT          NOT NULL COMMENT '发放总量',
   total_count       INT          NOT NULL COMMENT '发放总量',
   remain_count      INT          NOT NULL COMMENT '剩余数量',
   remain_count      INT          NOT NULL COMMENT '剩余数量',
   received_count    INT          DEFAULT 0 COMMENT '已领取数量',
   received_count    INT          DEFAULT 0 COMMENT '已领取数量',
@@ -266,8 +330,8 @@ CREATE TABLE promotion_coupon_rule (
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   batch_id          BIGINT   NOT NULL COMMENT '关联券批次',
   batch_id          BIGINT   NOT NULL COMMENT '关联券批次',
   product_id        BIGINT   DEFAULT NULL COMMENT '商品ID(商品券用,满减券=NULL)',
   product_id        BIGINT   DEFAULT NULL COMMENT '商品ID(商品券用,满减券=NULL)',
-  is_mutex          TINYINT  DEFAULT 0 COMMENT '0=同享券 1=互斥券(满减券用)',
-  threshold         DECIMAL(10,2) DEFAULT NULL COMMENT '使用门槛(满X元可用)',
+  is_mutex          TINYINT  DEFAULT 0 COMMENT '0=同享券 1=互斥券(满减券使;商品券此字段为NULL,互斥逻辑由促销活动判断)',
+  threshold         DECIMAL(10,2) DEFAULT NULL COMMENT '使用门槛:满减券=促销后订单金额门槛,商品券=该商品小计金额门槛(单价×数量)',
   amount            DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减券/抵用券)',
   amount            DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减券/抵用券)',
   discount_rate     DECIMAL(3,2)  DEFAULT NULL COMMENT '折扣率(商品折扣券用)',
   discount_rate     DECIMAL(3,2)  DEFAULT NULL COMMENT '折扣率(商品折扣券用)',
   INDEX idx_batch (batch_id)
   INDEX idx_batch (batch_id)
@@ -294,7 +358,7 @@ CREATE TABLE promotion_user_coupon (
 
 
 ### pos_order_promotion — 订单优惠明细表
 ### pos_order_promotion — 订单优惠明细表
 
 
-下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。
+下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。由算价接口在下单时写入。
 
 
 ```sql
 ```sql
 CREATE TABLE pos_order_promotion (
 CREATE TABLE pos_order_promotion (
@@ -376,7 +440,7 @@ CREATE TABLE pos_order_promotion (
 ### Measurable Outcomes
 ### Measurable Outcomes
 
 
 - **SC-001**: 商家能在3分钟内完成一个满减活动的创建(包括设置3个档位)
 - **SC-001**: 商家能在3分钟内完成一个满减活动的创建(包括设置3个档位)
-- **SC-002**: 商家能在3分钟内完成一个分组折扣活动的创建(建2个档位 + 分配5个商品
+- **SC-002**: 商家能在3分钟内完成一个折扣活动的创建(选择5个商品 + 逐个设折扣率
 - **SC-003**: 商家能在2分钟内完成一张优惠券的创建
 - **SC-003**: 商家能在2分钟内完成一张优惠券的创建
 - **SC-004**: 用户下单时优惠计算正确,结算页实时展示优惠明细
 - **SC-004**: 用户下单时优惠计算正确,结算页实时展示优惠明细
 - **SC-005**: 满100%覆盖互斥规则:满减/折扣/第二份半价三选一,新客立减可叠加
 - **SC-005**: 满100%覆盖互斥规则:满减/折扣/第二份半价三选一,新客立减可叠加
@@ -385,11 +449,11 @@ CREATE TABLE pos_order_promotion (
 
 
 - **旧代码不动**:`SalesPromotion`、`VipQuanyi`、`VipUserQuanyi` 等旧实体和旧接口全部废弃,新功能完全新建文件
 - **旧代码不动**:`SalesPromotion`、`VipQuanyi`、`VipUserQuanyi` 等旧实体和旧接口全部废弃,新功能完全新建文件
 - **新表前缀 `promotion_`**:与旧表(`sales_promotion`、`vip_quanyi`)完全独立
 - **新表前缀 `promotion_`**:与旧表(`sales_promotion`、`vip_quanyi`)完全独立
-- **工作范围**:只有后端 API + 商家 PC 端前端(foodie-store)
-- **用户端不在本范围**:小程序端的领券、下单优惠计算、结算页展示后续单独做
+- **工作范围**:后端 API(商家端管理接口 + 用户端接口)+ 商家 PC 端前端(foodie-store)
+- **用户端前端不在本范围**:小程序端的领券页面、结算页展示后续单独做,但后端接口(FR-011~022)本期实施
 - 促销和优惠券都是门店级别(storeId),不是跨店通用
 - 促销和优惠券都是门店级别(storeId),不是跨店通用
-- `pos_order_promotion`(订单优惠明细表):下单时快照记录优惠明细,单表扁平设计,优惠类型级别,不做 SKU 分摊。详见 Data Model 章节
-- 现有 `pos_order` 表的优惠汇总字段(`mdSalesReduction`、`mdDiscountAmount` 等)保留不动,新表明细作为补充
+- `pos_order_promotion`(订单优惠明细表):下单时快照记录优惠明细,单表扁平设计,优惠类型级别,不做 SKU 分摊。本期建表并实现写入逻辑
+- 现有 `pos_order` 表的优惠汇总字段(`mdSalesReduction`、`mdDiscountAmount` 等)保留不动,`pos_order_promotion` 明细表作为补充
 - 平台券不在本需求范围内,只做商家级促销和商家券
 - 平台券不在本需求范围内,只做商家级促销和商家券
 - SQL 变更写入 `updatesql/sql.md`,由开发者手动执行
 - SQL 变更写入 `updatesql/sql.md`,由开发者手动执行
 
 
@@ -401,3 +465,4 @@ CREATE TABLE pos_order_promotion (
 - 优惠券系统设计(知乎)
 - 优惠券系统设计(知乎)
 - 大厂优惠券系统设计(掘金)
 - 大厂优惠券系统设计(掘金)
 - Demo文件:`meituan-promotion-types-demo.html`、`merchant-promotion-ui-demo.html`、`meituan-checkout-demo.html`
 - Demo文件:`meituan-promotion-types-demo.html`、`merchant-promotion-ui-demo.html`、`meituan-checkout-demo.html`
+- [美团折扣商品调研报告](../../docs/research-meituan-discount-product.md) — 美团/饿了么折扣商品设置方式对比,逐商品设折扣 vs 折扣区分组

+ 218 - 46
specs/008-promotion-coupon/tasks.md

@@ -15,8 +15,8 @@
 
 
 **Purpose**: 创建数据库表,为后续所有任务提供基础
 **Purpose**: 创建数据库表,为后续所有任务提供基础
 
 
-- [ ] T001 追加 5 张新表 DDL 到 `foodie_server/updatesql/sql.md`(promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon),DDL 来自 spec.md Data Model 章节
-- [ ] T002 手动执行 SQL 建表(提醒开发者操作)
+- [x] T001 追加 6 张新表 DDL 到 `foodie_server/updatesql/sql.md`(promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_order_promotion),DDL 来自 spec.md Data Model 章节
+- [x] T002 手动执行 SQL 建表(提醒开发者操作)
 
 
 ---
 ---
 
 
@@ -26,27 +26,30 @@
 
 
 ### 实体类 (5个文件,全部可并行)
 ### 实体类 (5个文件,全部可并行)
 
 
-- [ ] T003 [P] 创建 PromotionActivity 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivity.java` — 字段: id, storeId, type, name, status, startTime, endTime, createTime, updateTime;非持久化: List<PromotionActivityRule> rules, boolean editable。使用 @Data @TableName @TableId(type=IdType.AUTO) @JsonFormat 注解,参考 SalesPromotion.java
-- [ ] T004 [P] 创建 PromotionActivityRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivityRule.java` — 字段: id, activityId, productId, threshold(BigDecimal), reduceAmount(BigDecimal), discountRate(BigDecimal), minQuantity(Integer);非持久化: productName, productImage, productPrice
-- [ ] T005 [P] 创建 PromotionCouponBatch 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponBatch.java` — 字段: id, storeId, name, couponType, totalCount, remainCount, receivedCount, status, startTime, endTime, validDays, createTime, updateTime;非持久化: PromotionCouponRule rule, boolean editable
-- [ ] T006 [P] 创建 PromotionCouponRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponRule.java` — 字段: id, batchId, productId, isMutex(Integer), threshold(BigDecimal), amount(BigDecimal), discountRate(BigDecimal);非持久化: productName, productImage, productPrice
-- [ ] T007 [P] 创建 PromotionUserCoupon 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionUserCoupon.java` — 字段: id, userId, batchId, storeId, status, orderId, receiveTime, useTime, expireTime
+- [x] T003 [P] 创建 PromotionActivity 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivity.java` — 字段: id, storeId, type, name, status, startTime, endTime, createTime, updateTime;非持久化: List<PromotionActivityRule> rules, boolean editable。使用 @Data @TableName @TableId(type=IdType.AUTO) @JsonFormat 注解,参考 SalesPromotion.java
+- [x] T004 [P] 创建 PromotionActivityRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivityRule.java` — 字段: id, activityId, productId, threshold(BigDecimal), reduceAmount(BigDecimal), discountRate(BigDecimal), minQuantity(Integer);非持久化: productName, productImage, productPrice
+- [x] T005 [P] 创建 PromotionCouponBatch 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponBatch.java` — 字段: id, storeId, name, couponType, totalCount, remainCount, receivedCount, status, startTime, endTime, validDays, createTime, updateTime;非持久化: PromotionCouponRule rule, boolean editable
+- [x] T006 [P] 创建 PromotionCouponRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponRule.java` — 字段: id, batchId, productId, isMutex(Integer), threshold(BigDecimal), amount(BigDecimal), discountRate(BigDecimal);非持久化: productName, productImage, productPrice
+- [x] T007 [P] 创建 PromotionUserCoupon 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionUserCoupon.java` — 字段: id, userId, batchId, storeId, status, orderId, receiveTime, useTime, expireTime
+- [x] T007a [P] 创建 PosOrderPromotion 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderPromotion.java` — 字段: id, orderId, promoType, promoSubType, promoId, userCouponId, promoName, promoDetail, reduceAmount, pathSummary, createTime
 
 
-### Mapper 接口 (5个文件,全部可并行)
+### Mapper 接口 (全部可并行)
 
 
-- [ ] T008 [P] 创建 PromotionActivityMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java` — extends BaseMapper<PromotionActivity>
-- [ ] T009 [P] 创建 PromotionActivityRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityRuleMapper.java` — extends BaseMapper<PromotionActivityRule>,额外方法 List<PromotionActivityRule> selectRulesByActivityId(Long activityId)
-- [ ] T010 [P] 创建 PromotionCouponBatchMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponBatchMapper.java` — extends BaseMapper<PromotionCouponBatch>
-- [ ] T011 [P] 创建 PromotionCouponRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponRuleMapper.java` — extends BaseMapper<PromotionCouponRule>,额外方法 PromotionCouponRule selectRuleByBatchId(Long batchId)
-- [ ] T012 [P] 创建 PromotionUserCouponMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionUserCouponMapper.java` — extends BaseMapper<PromotionUserCoupon>
+- [x] T008 [P] 创建 PromotionActivityMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java` — extends BaseMapper<PromotionActivity>
+- [x] T009 [P] 创建 PromotionActivityRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityRuleMapper.java` — extends BaseMapper<PromotionActivityRule>,额外方法 List<PromotionActivityRule> selectRulesByActivityId(Long activityId)
+- [x] T010 [P] 创建 PromotionCouponBatchMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponBatchMapper.java` — extends BaseMapper<PromotionCouponBatch>
+- [x] T011 [P] 创建 PromotionCouponRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponRuleMapper.java` — extends BaseMapper<PromotionCouponRule>,额外方法 PromotionCouponRule selectRuleByBatchId(Long batchId)
+- [x] T012 [P] 创建 PromotionUserCouponMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionUserCouponMapper.java` — extends BaseMapper<PromotionUserCoupon>
+- [x] T012a [P] 创建 PosOrderPromotionMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderPromotionMapper.java` — extends BaseMapper<PosOrderPromotion>
 
 
-### Mapper XML (5个文件,全部可并行)
+### Mapper XML (全部可并行)
 
 
-- [ ] T013 [P] 创建 PromotionActivityMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml` — resultMap 映射全部字段
-- [ ] T014 [P] 创建 PromotionActivityRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityRuleMapper.xml` — resultMap + selectRulesByActivityId (LEFT JOIN pos_food 获取 productName/productImage/productPrice)
-- [ ] T015 [P] 创建 PromotionCouponBatchMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponBatchMapper.xml` — resultMap 映射全部字段
-- [ ] T016 [P] 创建 PromotionCouponRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponRuleMapper.xml` — resultMap + selectRuleByBatchId (LEFT JOIN pos_food)
-- [ ] T017 [P] 创建 PromotionUserCouponMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionUserCouponMapper.xml` — resultMap 映射全部字段
+- [x] T013 [P] 创建 PromotionActivityMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml` — resultMap 映射全部字段
+- [x] T014 [P] 创建 PromotionActivityRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityRuleMapper.xml` — resultMap + selectRulesByActivityId (LEFT JOIN pos_food 获取 productName/productImage/productPrice)
+- [x] T015 [P] 创建 PromotionCouponBatchMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponBatchMapper.xml` — resultMap 映射全部字段
+- [x] T016 [P] 创建 PromotionCouponRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponRuleMapper.xml` — resultMap + selectRuleByBatchId (LEFT JOIN pos_food)
+- [x] T017 [P] 创建 PromotionUserCouponMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionUserCouponMapper.xml` — resultMap 映射全部字段
+- [x] T017a [P] 创建 PosOrderPromotionMapper.xml `ruoyi-system/src/main/resources/mapper/system/PosOrderPromotionMapper.xml` — resultMap 映射全部字段
 
 
 **Checkpoint**: 数据层完成,可进入 Service 和 Controller
 **Checkpoint**: 数据层完成,可进入 Service 和 Controller
 
 
@@ -60,29 +63,30 @@
 
 
 ### Service 层 (US1)
 ### Service 层 (US1)
 
 
-- [ ] T018 [US1] 创建 IPromotionActivityService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java` — extends IService<PromotionActivity>,方法: createActivity(activity, rules), selectActivityWithRules(id), endActivity(id)
-- [ ] T019 [US1] 创建 PromotionActivityServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java` — 实现 createActivity (@Transactional: 插入activity+批量插入rules, 根据 startTime 设置初始 status), selectActivityWithRules (查activity+查rules), endActivity (status→2)
-- [ ] T020 [US1] 创建 IPromotionActivityRuleService + PromotionActivityRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityRuleService.java` 和 `impl/PromotionActivityRuleServiceImpl.java` — selectRulesByActivityId 委托给 mapper
+- [x] T018 [US1] 创建 IPromotionActivityService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java` — extends IService<PromotionActivity>,方法: createActivity(activity, rules), selectActivityWithRules(id), endActivity(id)
+- [x] T019 [US1] 创建 PromotionActivityServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java` — 实现 createActivity (@Transactional: 插入activity+批量插入rules, 根据 startTime 设置初始 status), selectActivityWithRules (查activity+查rules), endActivity (status→2)
+- [x] T020 [US1] 创建 IPromotionActivityRuleService + PromotionActivityRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityRuleService.java` 和 `impl/PromotionActivityRuleServiceImpl.java` — selectRulesByActivityId 委托给 mapper
 
 
 ### Controller (US1)
 ### Controller (US1)
 
 
-- [ ] T021 [US1] 创建 ShPromotionActivityController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java` — @RestController @RequestMapping("/system/shPromotionActivity"),参考 ShSalesPromotionController。端点: GET /list (page,size,storeId,type?,status? → LambdaQueryWrapper 分页), GET /{id} (selectActivityWithRules), POST / (RequestBody含rules→createActivity), PUT /{id}/end (endActivity)
+- [x] T021 [US1] 创建 ShPromotionActivityController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java` — @RestController @RequestMapping("/system/shPromotionActivity"),参考 ShSalesPromotionController。端点: GET /list (page,size,storeId,type?,status? → LambdaQueryWrapper 分页), GET /{id} (selectActivityWithRules), POST / (RequestBody含rules→createActivity), PUT /{id}/end (endActivity)
 
 
 ### 前端 API (US1)
 ### 前端 API (US1)
 
 
-- [ ] T022 [US1] 创建 promotionActivity.js `foodie-store/src/api/promotionActivity.js` — 导出 listPromotionActivity(query), getPromotionActivity(id), addPromotionActivity(data), endPromotionActivity(id),参考 src/api/promotion.js 模式
+- [x] T022 [US1] 创建 promotionActivity.js `foodie-store/src/api/promotionActivity.js` — 导出 listPromotionActivity(query), getPromotionActivity(id), addPromotionActivity(data), endPromotionActivity(id),参考 src/api/promotion.js 模式
 
 
 ### 前端页面 (US1)
 ### 前端页面 (US1)
 
 
-- [ ] T023 [US1] 创建 PromotionActivity.vue `foodie-store/src/views/PromotionActivity.vue` — 促销活动管理页面:
+- [x] T023 [US1] 创建 PromotionActivity.vue `foodie-store/src/views/PromotionActivity.vue` — 促销活动管理页面:
   - **列表区域**: 面包屑 + 门店选择(复用xuanzhemd模式) + 状态筛选radio(全部/未开始/进行中/已结束) + el-table(ID/名称/类型el-tag/状态el-tag/时间/操作-结束按钮) + el-pagination
   - **列表区域**: 面包屑 + 门店选择(复用xuanzhemd模式) + 状态筛选radio(全部/未开始/进行中/已结束) + el-table(ID/名称/类型el-tag/状态el-tag/时间/操作-结束按钮) + el-pagination
   - **创建对话框** el-dialog width=750px + el-tabs v-model=activeTab:
   - **创建对话框** el-dialog width=750px + el-tabs v-model=activeTab:
     - Tab1 满减(type=1): 活动名称 + 时间范围 + 动态档位行(v-for, 每行两个el-input-number:满X减Y) + 添加/删除档位按钮
     - Tab1 满减(type=1): 活动名称 + 时间范围 + 动态档位行(v-for, 每行两个el-input-number:满X减Y) + 添加/删除档位按钮
-    - Tab2 折扣(type=2): 活动名称 + 时间范围 + 折扣组列表(v-for, 每组含折扣率+已选商品table+添加商品按钮) + 添加/删除折扣区按钮
+    - Tab2 折扣(type=2): 活动名称 + 时间范围 + 折扣组列表(v-for, 每组含折扣率+已选商品table+添加商品按钮) + 添加/删除折扣区按钮。**同一商品只能分配到一个折扣区,添加时自动从其他折扣区移除**
     - Tab3 第二份半价(type=3): 活动名称 + 时间范围 + 已选商品table + 选择商品按钮
     - Tab3 第二份半价(type=3): 活动名称 + 时间范围 + 已选商品table + 选择商品按钮
     - Tab4 新客立减(type=4): 活动名称 + 时间范围 + 减免金额el-input-number
     - Tab4 新客立减(type=4): 活动名称 + 时间范围 + 减免金额el-input-number
   - **商品选择弹窗**: 分类el-select + 搜索el-input + 商品el-table + el-pagination (复用Quanyi.vue商品加载API模式)
   - **商品选择弹窗**: 分类el-select + 搜索el-input + 商品el-table + el-pagination (复用Quanyi.vue商品加载API模式)
   - **提交逻辑** tijiaobaocun(): 根据activeTab转换数据为 {storeId,type,name,startTime,endTime,rules:[...]},折扣组展开为扁平rules,调用addPromotionActivity
   - **提交逻辑** tijiaobaocun(): 根据activeTab转换数据为 {storeId,type,name,startTime,endTime,rules:[...]},折扣组展开为扁平rules,调用addPromotionActivity
+  - **互斥提示**: 创建对话框顶部展示互斥规则提示文案(FR-006):"满减、折扣、第二份半价三选一(系统自动选最优),新客立减可叠加"
   - **i18n**: 所有文字用 $t('promoActivity.xxx')
   - **i18n**: 所有文字用 $t('promoActivity.xxx')
 
 
 **Checkpoint**: 促销活动完整功能可用——创建4种类型活动、查看列表、查看详情、结束活动
 **Checkpoint**: 促销活动完整功能可用——创建4种类型活动、查看列表、查看详情、结束活动
@@ -97,21 +101,21 @@
 
 
 ### Service 层 (US2)
 ### Service 层 (US2)
 
 
-- [ ] T024 [P] [US2] 创建 IPromotionCouponBatchService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponBatchService.java` — 方法: createBatch(batch, rule), selectBatchWithRule(id), offShelfBatch(id)
-- [ ] T025 [P] [US2] 创建 PromotionCouponBatchServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java` — 实现 createBatch (@Transactional: remainCount=totalCount, 插入batch+rule), selectBatchWithRule (查batch+查rule), offShelfBatch (status→3)
-- [ ] T026 [P] [US2] 创建 IPromotionCouponRuleService + PromotionCouponRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponRuleService.java` 和 `impl/PromotionCouponRuleServiceImpl.java` — selectRuleByBatchId 委托给 mapper
+- [x] T024 [P] [US2] 创建 IPromotionCouponBatchService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponBatchService.java` — 方法: createBatch(batch, rule), selectBatchWithRule(id), offShelfBatch(id)
+- [x] T025 [P] [US2] 创建 PromotionCouponBatchServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java` — 实现 createBatch (@Transactional: remainCount=totalCount, 插入batch+rule), selectBatchWithRule (查batch+查rule), offShelfBatch (status→3)
+- [x] T026 [P] [US2] 创建 IPromotionCouponRuleService + PromotionCouponRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponRuleService.java` 和 `impl/PromotionCouponRuleServiceImpl.java` — selectRuleByBatchId 委托给 mapper
 
 
 ### Controller (US2)
 ### Controller (US2)
 
 
-- [ ] T027 [US2] 创建 ShPromotionCouponController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java` — @RestController @RequestMapping("/system/shPromotionCoupon")。端点: GET /list (page,size,storeId,couponType?,status?), GET /{id} (selectBatchWithRule), POST / (RequestBody含rule→createBatch), PUT /{id}/offShelf
+- [x] T027 [US2] 创建 ShPromotionCouponController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java` — @RestController @RequestMapping("/system/shPromotionCoupon")。端点: GET /list (page,size,storeId,couponType?,status?), GET /{id} (selectBatchWithRule), POST / (RequestBody含rule→createBatch), PUT /{id}/offShelf
 
 
 ### 前端 API (US2)
 ### 前端 API (US2)
 
 
-- [ ] T028 [P] [US2] 创建 promotionCoupon.js `foodie-store/src/api/promotionCoupon.js` — 导出 listPromotionCoupon(query), getPromotionCoupon(id), addPromotionCoupon(data), offShelfPromotionCoupon(id)
+- [x] T028 [P] [US2] 创建 promotionCoupon.js `foodie-store/src/api/promotionCoupon.js` — 导出 listPromotionCoupon(query), getPromotionCoupon(id), addPromotionCoupon(data), offShelfPromotionCoupon(id)
 
 
 ### 前端页面 (US2)
 ### 前端页面 (US2)
 
 
-- [ ] T029 [US2] 创建 CouponBatch.vue `foodie-store/src/views/CouponBatch.vue` — 优惠券管理页面:
+- [x] T029 [US2] 创建 CouponBatch.vue `foodie-store/src/views/CouponBatch.vue` — 优惠券管理页面:
   - **列表区域**: 面包屑 + 门店选择 + 状态筛选(全部/未开始/进行中/已结束/已下架) + el-table(ID/名称/类型/总库存/剩余/已领取/状态/时间/有效期/操作-下架按钮) + el-pagination
   - **列表区域**: 面包屑 + 门店选择 + 状态筛选(全部/未开始/进行中/已结束/已下架) + el-table(ID/名称/类型/总库存/剩余/已领取/状态/时间/有效期/操作-下架按钮) + el-pagination
   - **创建对话框** el-dialog width=600px:
   - **创建对话框** el-dialog width=600px:
     - 券类型 el-radio-group (满减券/商品券)
     - 券类型 el-radio-group (满减券/商品券)
@@ -131,23 +135,185 @@
 
 
 > **注意**: 前端文件使用 CRLF 换行,必须用 Python 脚本编辑,不能用 Edit 工具。
 > **注意**: 前端文件使用 CRLF 换行,必须用 Python 脚本编辑,不能用 Edit 工具。
 
 
-- [ ] T030 在 `foodie-store/src/router/index.js` 的 /manage children 中添加路由 promotion-activity → PromotionActivity.vue 和 coupon-batch → CouponBatch.vue
-- [ ] T031 在 `foodie-store/src/components/Aside.vue` 末尾(el-submenu index="6" 之后)添加新的 el-submenu index="7"「营销管理」,含两个 menu-item: 促销活动(/manage/promotion-activity) + 优惠券管理(/manage/coupon-batch)
-- [ ] T032 [P] 在 `foodie-store/src/lang/zh.js` 末尾追加 promoMenu(营销管理/促销活动/优惠券管理)、promoActivity(~40个key覆盖页面全部文案)、couponBatch(~30个key)三个 i18n section
-- [ ] T033 [P] 在 `foodie-store/src/lang/tw.js` 追加同结构繁体中文翻译
-- [ ] T034 [P] 在 `foodie-store/src/lang/en.js` 追加同结构英文翻译
-- [ ] T035 [P] 在 `foodie-store/src/lang/vi.js` 追加同结构越南语翻译
+- [x] T030 在 `foodie-store/src/router/index.js` 的 /manage children 中添加路由 promotion-activity → PromotionActivity.vue 和 coupon-batch → CouponBatch.vue
+- [x] T031 在 `foodie-store/src/components/Aside.vue` 末尾(el-submenu index="6" 之后)添加新的 el-submenu index="7"「营销管理」,含两个 menu-item: 促销活动(/manage/promotion-activity) + 优惠券管理(/manage/coupon-batch)
+- [x] T032 [P] 在 `foodie-store/src/lang/zh.js` 末尾追加 promoMenu(营销管理/促销活动/优惠券管理)、promoActivity(~40个key覆盖页面全部文案)、couponBatch(~30个key)三个 i18n section
+- [x] T033 [P] 在 `foodie-store/src/lang/tw.js` 追加同结构繁体中文翻译
+- [x] T034 [P] 在 `foodie-store/src/lang/en.js` 追加同结构英文翻译
+- [x] T035 [P] 在 `foodie-store/src/lang/vi.js` 追加同结构越南语翻译
 
 
 **Checkpoint**: 侧边栏显示新菜单,页面可访问,多语言正常
 **Checkpoint**: 侧边栏显示新菜单,页面可访问,多语言正常
 
 
 ---
 ---
 
 
+## Phase 5.5: 用户端后端接口 (FR-011~022)
+
+**Purpose**: 实现用户端的领券、查券、算价计算后端接口(前端界面由小程序实施,不做)
+
+**Goal**: 用户端小程序可调用后端 API 完成领券、查看优惠券、结算页优惠计算
+
+**Independent Test**: 用 Postman 调用算价接口,传入购物车商品,返回两条路径对比 + 最优方案 + 可用券列表
+
+### Service 层
+
+- [x] T039 [P] 创建 IPromotionUserCouponService + PromotionUserCouponServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionUserCouponService.java` 和 `impl/PromotionUserCouponServiceImpl.java` — 方法: receiveCoupon(userId, batchId, storeId)(检查限领1张、**乐观锁扣库存 `UPDATE remain_count=remain_count-1 WHERE remain_count>0`**、计算expireTime=领取日期+validDays天23:59:59、@Transactional), selectStoreCouponsForUser(userId, storeId)(查询门店进行中券批次+用户是否已领标记,不分页), selectMyCoupons(userId, status?, storeId?)(返回含storeName,实时判断expire_time<NOW()则status→2)
+- [x] T040 创建 IPromotionCalcService + PromotionCalcServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java` 和 `impl/PromotionCalcServiceImpl.java` — 核心算价逻辑:
+  - calculate(storeId, items[{productId,quantity}], userId, couponId?, forcePath?) → 返回路径对比结果
+  - 内部逻辑:
+    1. **根据productId从数据库查询商品实际价格**(不信任前端传价)
+    2. 查门店所有进行中的促销活动(满减、折扣、第二份半价、新客立减)
+    3. 路径A(走折扣/第二份半价):分别计算折扣和第二份半价各一遍,取更优作为路径A结果。折扣商品按discountRate,第二份半价按每2件第2件半价,非活动商品原价
+    4. 路径B(走满减):所有商品原价 → 匹配满减最高可达档位 → 合计
+    5. 对比选最优(金额相等默认选B);无促销时两条路径同价返回原价
+    6. 查用户是否为该门店新客(查询该门店已完成订单数=0),新客则叠加新客立减
+    7. 如传couponId:检查互斥规则(互斥券不能与满减/折扣叠加,商品券对应商品如在折扣/第二份半价活动中则该券不可用),同享券threshold基于促销后金额判断
+    8. **所有金额用BigDecimal计算,最终ROUND_HALF_UP保留2位小数,最低¥0.01**
+    9. 返回 availableCoupons(可用券列表)供前端展示
+    6. 返回两条路径明细 + optimalPath + 最终金额 + details数组
+
+### Controller
+
+- [x] T041 创建 UserPromotionCouponController `ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCouponController.java` — @RestController @RequestMapping("/app/userPromotionCoupon")。端点: GET /storeCoupons (storeId → selectStoreCouponsForUser), POST /receive (batchId,storeId → receiveCoupon), GET /myCoupons (status?,storeId? → selectMyCoupons)
+- [x] T042 创建 UserPromotionCalcController `ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java` — @RestController @RequestMapping("/app/userPromotionCalc")。端点: POST /calculate (RequestBody含storeId+items[{productId,quantity}]+couponId?+forcePath?,**不传price**,通过登录态获取userId → calculate)
+
+### Mapper 补充查询
+
+- [x] T043 [P] 在 PromotionActivityMapper 中添加 selectActiveByStoreId(storeId) — 查询门店所有进行中的促销活动,算价时使用
+- [x] T044 [P] 在 PromotionActivityRuleMapper 中添加 selectActiveRulesByStoreId(storeId) — 一次性查出门店所有进行中活动的规则(LEFT JOIN promotion_activity ON status=1),算价时使用
+- [x] T045 [P] 在 PromotionCouponBatchMapper 中添加 selectActiveByStoreId(storeId) — 查询门店进行中且有余量的券批次(status=1 AND remain_count>0)
+
+**Checkpoint**: 用户端 API 可用——领券、查券、算价计算接口通过 Postman 测试通过
+
+---
+
+## Phase 5.6: 促销活动修改/删除 (FR-005a, FR-005b)
+
+**Purpose**: 商家能修改未开始的活动、删除未开始的活动,参考美团商家端交互
+
+**Goal**: 活动列表操作列按状态显示不同按钮(未开始=编辑+删除,进行中=结束,已结束=无)
+
+### 后端
+
+- [x] T046 在 IPromotionActivityService 中添加 `updateActivity(activity, rules)` — 校验 status=0 才允许修改;@Transactional 先删除旧规则再重新插入
+- [x] T047 在 IPromotionActivityService 中添加 `deleteActivity(id)` — 校验 status=0 才允许删除;@Transactional 删除活动+关联规则
+- [x] T048 在 ShPromotionActivityController 中添加 `PUT /` (updateActivity) 和 `DELETE /{id}` (deleteActivity) 端点
+
+### 前端 API
+
+- [x] T049 在 `foodie-store/src/api/promotionActivity.js` 中添加 `updatePromotionActivity(data)` 和 `deletePromotionActivity(id)`
+
+### 前端页面
+
+- [x] T050 修改 `foodie-store/src/views/PromotionActivity.vue`:
+  - 操作列按状态显示按钮:未开始(编辑+删除) / 进行中(结束) / 已结束(无)
+  - 编辑:点击后调用 getPromotionActivity(id) 获取详情,回填到创建对话框,tijiaobaocun 判断有 id 则调用 updatePromotionActivity
+  - 删除:二次确认后调用 deletePromotionActivity(id)
+  - 新增 data: `editingId: null`,`tijiaobaocun` 判断 editingId 有值走修改、无值走新增
+
+### i18n
+
+- [x] T051 [P] 在 4 个语言文件(zh/tw/en/vi)的 promoActivity section 中追加:editBtn、deleteBtn、editActivity、deleteConfirm 等新 key
+
+**Checkpoint**: 未开始的活动可编辑(修改名称/时间/规则)、可删除(二次确认后删除),进行中只能结束
+
+---
+
+## Phase 5.7: 优惠券修改/删除 (FR-009a, FR-009b)
+
+**Purpose**: 商家能修改未开始/进行中的优惠券、删除未开始的优惠券
+
+**Goal**: 优惠券列表操作列按状态显示不同按钮(未开始=编辑+删除,进行中=编辑+下架,已结束/已下架=无)
+
+### 后端
+
+- [x] T052 在 IPromotionCouponBatchService 中添加 `updateBatch(batch, rule)` — 校验 status=0或1 才允许修改;@Transactional 先删除旧规则再重新插入
+- [x] T053 在 IPromotionCouponBatchService 中添加 `deleteBatch(id)` — 校验 status=0 才允许删除;@Transactional 删除券批次+关联规则
+- [x] T054 在 ShPromotionCouponController 中添加 `PUT /` (updateBatch) 和 `DELETE /{id}` (deleteBatch) 端点
+
+### 前端 API
+
+- [x] T055 在 `foodie-store/src/api/promotionCoupon.js` 中添加 `updatePromotionCoupon(data)` 和 `deletePromotionCoupon(id)`
+
+### 前端页面
+
+- [x] T056 修改 `foodie-store/src/views/CouponBatch.vue`:
+  - 操作列按状态显示按钮:未开始(编辑+删除) / 进行中(编辑+下架) / 已结束/已下架(无)
+  - 编辑:点击后调用 getPromotionCoupon(id) 获取详情,回填到创建对话框,tijiaobaocun 判断有 id 则调用 updatePromotionCoupon
+  - 删除:二次确认后调用 deletePromotionCoupon(id)
+  - 新增 data: `editingId: null`,`tijiaobaocun` 判断 editingId 有值走修改、无值走新增
+
+### i18n
+
+- [x] T057 [P] 在 4 个语言文件(zh/tw/en/vi)的 couponBatch section 中追加:editBtn、deleteBtn、editCoupon、deleteConfirm 等新 key
+
+---
+
+## Phase 5.8: 免配送费券 (FR-008a)
+
+**Purpose**: 新增 couponType=3 免配送费券,商家可创建减免配送费的优惠券
+
+**Goal**: 创建券对话框新增"免配送费券"选项,配置使用门槛+减免金额+同享/互斥
+
+### 数据库
+
+- [x] T058 在 `updatesql/sql.md` 追加 ALTER 语句更新 promotion_coupon_batch.coupon_type 的 COMMENT 为 '1=满减券 2=商品券 3=免配送费券'(无需实际 ALTER,仅记录)
+
+### 前端页面
+
+- [x] T059 修改 `foodie-store/src/views/CouponBatch.vue` 创建对话框:券类型 radio-group 新增第三个选项"免配送费券"(couponType=3),选中后显示:使用门槛 el-input-number + 减免金额 el-input-number + 叠加属性 radio(同享/互斥),隐藏商品选择
+
+### i18n
+
+- [x] T060 [P] 在 4 个语言文件(zh/tw/en/vi)的 couponBatch section 中追加:typeFreeDelivery(免配送费券)等 key
+
+**Checkpoint**: 创建券时可选"免配送费券",提交数据 couponType=3,含 threshold + amount + isMutex
+
+**Checkpoint**: 未开始的券可编辑+删除,进行中的券可编辑+下架,已结束/已下架无操作
+
+---
+
+## Phase 5.9: 折扣商品改为逐商品设折扣 (需求变更)
+
+**Purpose**: 将折扣商品 UI 从「折扣区分组」改为「逐商品设折扣」(参考美团),提升商家体验
+
+**Goal**: 商家创建折扣活动时直接选择商品并逐个设折扣率,不再需要先建折扣区
+
+**背景**: 经美团/饿了么调研,主流外卖平台都是逐商品设折扣,不使用折扣区分组。后端数据模型已支持(`promotion_activity_rule` 每行有独立 `productId` + `discountRate`),改动集中在前端 UI。
+
+### 前端页面
+
+- [x] T061 修改 `foodie-store/src/views/PromotionActivity.vue` 折扣 Tab (Tab2) 的 UI 和逻辑:
+  - **移除**:折扣区分组 UI(建区/删区按钮、折扣组 v-for 循环)
+  - **改为**扁平商品列表,每行 = 商品名称 + 原价 + 折扣率控件(-/+按钮+输入框,范围10%~99%,步长0.1) + 折后价(自动计算) + 移除按钮
+  - **添加商品按钮**:点击打开商品选择弹窗(复用已有弹窗),选中后默认折扣率90%,添加到列表
+  - **重复商品限制**:选择商品时如已存在于列表则提示并跳过
+  - **折后价实时计算**:原价 × 折扣率,显示在每行
+  - **提交逻辑简化**:`tijiaobaocun()` 中折扣 Tab 直接构建 `rules: [{productId, discountRate}, ...]`,无需展开分组
+  - **编辑回填**:编辑折扣活动时,从 `rules` 数组直接回填到商品列表,每行设置对应的 discountRate
+  - **折扣率校验**:前端提交时校验所有折扣率在 0.10~0.99 范围内,超出范围阻止提交
+
+### i18n
+
+- [x] T062 [P] 更新 4 个语言文件 `foodie-store/src/lang/` 的 zh.js、tw.js、en.js、vi.js:
+  - 移除折扣区相关 key(如 addDiscountZone、deleteZone、discountZone 等)
+  - 新增/修改:discountRate(折扣率)、discountedPrice(折后价)、originalPrice(原价)、removeProduct(移除)、addDiscountProduct(添加折扣商品)、discountRateRange(范围10%-99%)、productAlreadyAdded(商品已添加)等 key
+  - 更新 promoActivity section 中与折扣 Tab 相关的所有文案
+
+### 后端验证
+
+- [x] T063 验证后端接口兼容性:确认 `ShPromotionActivityController` 的 POST/PUT 端点已支持接收扁平的 `rules: [{productId, discountRate}]` 格式(不需要改动),确认编辑回填接口 GET /{id} 返回的 rules 中每条包含 productId 和 discountRate(已有 LEFT JOIN pos_food)
+
+**Checkpoint**: 商家创建折扣活动时 UI 为扁平商品列表,每个商品独立设折扣率,不再有折扣区分组概念。编辑时正确回填。折后价实时计算。后端接口无需改动。
+
+---
+
 ## Phase 6: Polish & 验证
 ## Phase 6: Polish & 验证
 
 
 **Purpose**: 端到端验证
 **Purpose**: 端到端验证
 
 
-- [ ] T036 后端启动验证:用 Postman 测试 4 个促销 API + 4 个优惠券 API 的完整 CRUD 流程
-- [ ] T037 前端启动验证:npm run dev → 侧边栏新菜单 → 创建满减活动(3档位) → 创建折扣活动(2组+商品) → 创建第二份半价 → 创建新客立减 → 创建满减券 → 创建商品券 → 列表筛选 → 结束活动 → 下架券
+- [ ] T036 后端启动验证:用 Postman 测试 4 个促销 API + 4 个优惠券 API(商家端)+ 3 个用户端领券 API + 1 个算价 API 的完整流程
+- [ ] T036a 算价接口专项验证:构造测试数据(门店有满减+折扣活动+优惠券),验证两条路径计算正确、互斥规则生效、新客立减可叠加
+- [ ] T037 前端启动验证:npm run dev → 侧边栏新菜单 → 创建满减活动(3档位) → 创建折扣活动(选择5个商品逐个设不同折扣率) → 创建第二份半价 → 创建新客立减 → 创建满减券 → 创建商品券 → 列表筛选 → 结束活动 → 下架券 → 编辑折扣活动验证回填
 - [ ] T038 [P] 切换4种语言验证所有文案显示正确
 - [ ] T038 [P] 切换4种语言验证所有文案显示正确
 
 
 ---
 ---
@@ -161,23 +327,27 @@
 - **Phase 3 (US1 促销)**: 依赖 Phase 2 的实体和 Mapper
 - **Phase 3 (US1 促销)**: 依赖 Phase 2 的实体和 Mapper
 - **Phase 4 (US2 优惠券)**: 依赖 Phase 2,可与 Phase 3 并行
 - **Phase 4 (US2 优惠券)**: 依赖 Phase 2,可与 Phase 3 并行
 - **Phase 5 (Menu/i18n)**: 依赖 Phase 3 + Phase 4 的页面文件存在
 - **Phase 5 (Menu/i18n)**: 依赖 Phase 3 + Phase 4 的页面文件存在
-- **Phase 6 (验证)**: 依赖全部完成
+- **Phase 5.5 (用户端接口)**: 依赖 Phase 2 的实体/Mapper + Phase 3 的促销 Activity Service + Phase 4 的优惠券 Service
+- **Phase 6 (验证)**: 依赖全部完成(包括 Phase 5.9 折扣改动)
 
 
 ### Parallel Opportunities
 ### Parallel Opportunities
 
 
-- T003-T007 (5个实体) 可并行
-- T008-T012 (5个Mapper接口) 可并行
-- T013-T017 (5个Mapper XML) 可并行
+- T003-T007a (6个实体) 可并行
+- T008-T012a (6个Mapper接口) 可并行
+- T013-T017a (6个Mapper XML) 可并行
 - Phase 3 和 Phase 4 可并行(不同文件,不同 Controller)
 - Phase 3 和 Phase 4 可并行(不同文件,不同 Controller)
+- T039 和 T040 可并行(不同 Service)
 - T032-T035 (4个语言文件) 可并行
 - T032-T035 (4个语言文件) 可并行
 
 
 ### Recommended Execution Order
 ### Recommended Execution Order
 
 
 ```
 ```
 T001 → T002(手动)
 T001 → T002(手动)
-  → T003-T017 (Phase 2, 大量并行)
+  → T003-T017a (Phase 2, 大量并行)
   → T018-T023 (Phase 3, 促销活动) + T024-T029 (Phase 4, 优惠券) [可并行]
   → T018-T023 (Phase 3, 促销活动) + T024-T029 (Phase 4, 优惠券) [可并行]
   → T030-T035 (Phase 5, 菜单路由i18n)
   → T030-T035 (Phase 5, 菜单路由i18n)
+  → T039-T045 (Phase 5.5, 用户端接口)
+  → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣) ← 当前待实施
   → T036-T038 (Phase 6, 验证)
   → T036-T038 (Phase 6, 验证)
 ```
 ```
 
 
@@ -190,4 +360,6 @@ T001 → T002(手动)
 - 每个任务完成后 commit
 - 每个任务完成后 commit
 - 前端文件 CRLF 换行,用 Python 脚本编辑
 - 前端文件 CRLF 换行,用 Python 脚本编辑
 - 旧代码(SalesPromotion, VipQuanyi)不动
 - 旧代码(SalesPromotion, VipQuanyi)不动
-- PromotionUserCoupon 实体和 Mapper 在 Phase 2 创建但无 Service/Controller(用户端功能不在本范围)
+- PromotionUserCoupon 实体和 Mapper 在 Phase 2 创建,Service/Controller 在 Phase 5.5 实现
+- PosOrderPromotion 实体和 Mapper 在 Phase 2 创建,算价 Service 在 Phase 5.5 使用
+- 用户端前端(小程序界面)不在本范围,只做后端 API

+ 25 - 0
updatesql/sql.md

@@ -123,3 +123,28 @@ CREATE TABLE promotion_user_coupon (
   INDEX idx_store (store_id)
   INDEX idx_store (store_id)
 ) COMMENT '用户领券记录';
 ) COMMENT '用户领券记录';
 ```
 ```
+
+## 2026-06-01 订单优惠明细表(008-promotion-coupon)
+
+```sql
+CREATE TABLE pos_order_promotion (
+  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+  order_id        BIGINT       NOT NULL COMMENT '关联订单ID',
+  promo_type      TINYINT      NOT NULL COMMENT '优惠类型: 1=促销活动 2=商家优惠券',
+  promo_sub_type  TINYINT      DEFAULT NULL COMMENT '促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 (promo_type=1时有效)',
+  promo_id        BIGINT       DEFAULT NULL COMMENT '促销活动ID(promo_type=1) 或 券批次ID(promo_type=2)',
+  user_coupon_id  BIGINT       DEFAULT NULL COMMENT '用户券ID (仅优惠券 promo_type=2 时有值, 关联 promotion_user_coupon.id)',
+  promo_name      VARCHAR(200) NOT NULL COMMENT '快照名称: 如"午市满减(满40减12)"',
+  promo_detail    VARCHAR(500) DEFAULT NULL COMMENT '快照详情JSON: 如{"threshold":40,"reduce":12}',
+  reduce_amount   DECIMAL(10,2) NOT NULL COMMENT '减免金额',
+  path_summary    VARCHAR(500) DEFAULT NULL COMMENT '路径对比摘要, 仅第一条记录有值, 如"满减路径¥33 vs 折扣路径¥39, 选择满减"',
+  create_time     DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  INDEX idx_order (order_id)
+) COMMENT '订单优惠明细';
+```
+
+## 2026-06-01 优惠券类型扩展
+
+```
+-- 2026-06-01 优惠券类型扩展:coupon_type COMMENT 更新为 '1=满减券 2=商品券 3=免配送费券'(无需实际 ALTER,仅记录)
+```