Selaa lähdekoodia

提交促销优惠券管理

qmj 2 viikkoa sitten
vanhempi
sitoutus
fa1baac662
85 muutettua tiedostoa jossa 6308 lisäystä ja 325 poistoa
  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"
   source: "templates/commands/analyze.md"
 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).
 
+## 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
 
-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
 
 **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
 
@@ -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:
 
-- 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
-- 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
 
 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
 
 ### Context Efficiency

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

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   source: "templates/commands/checklist.md"
 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).
 
+## 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
 
 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
    - 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`)
 - Simple, memorable filenames that indicate checklist purpose
@@ -302,3 +337,36 @@ Sample items:
 - Correct: Validation of requirement quality
 - Wrong: "Does it do X?"
 - 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"
   source: "templates/commands/clarify.md"
 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).
 
+## 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
 
 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:
 
@@ -31,7 +66,7 @@ Execution steps:
    - `FEATURE_DIR`
    - `FEATURE_SPEC`
    - (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").
 
 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.
    - 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).
-   - 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.
 
 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 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).
 - Avoid speculative tech stack questions unless the absence blocks functional clarity.
 - 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.
 
 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"
   source: "templates/commands/constitution.md"
 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).
 
+## 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
 
 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.
 
 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"
   source: "templates/commands/implement.md"
 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:
   - 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`):
     ```
@@ -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 contracts/ for API specifications and test requirements
    - **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
 
 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
    - 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.
     - 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:
       - 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`):
         ```

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

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   source: "templates/commands/plan.md"
 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:
   - 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`):
     ```
@@ -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:
      - 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`):
        ```
@@ -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.)
 
 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
 
-- 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

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

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   source: "templates/commands/specify.md"
 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:
   - 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`):
     ```
@@ -55,11 +56,11 @@ You **MUST** consider the user input before proceeding (if not empty).
 
 ## 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:
 
-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
    - 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")
@@ -71,30 +72,47 @@ Given that feature description, do this:
      - "Create a dashboard for analytics" → "analytics-dashboard"
      - "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"
     2. Extract key concepts from description
        Identify: actors, actions, data, constraints
@@ -118,11 +136,11 @@ Given that feature description, do this:
     7. Identify Key Entities (if data involved)
     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
       # Specification Quality Checklist: [FEATURE NAME]
@@ -158,7 +176,7 @@ Given that feature description, do this:
       
       ## 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:
@@ -167,7 +185,7 @@ Given that feature description, do this:
 
    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])**:
         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
 
-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 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`):
        ```
@@ -243,7 +266,7 @@ Given that feature description, do this:
        ```
    - 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
 

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

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   source: "templates/commands/tasks.md"
 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:
   - 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`):
     ```
@@ -55,7 +56,7 @@ You **MUST** consider the user input before proceeding (if not empty).
 
 ## 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:
    - **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
    - 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
    - Phase 1: Setup tasks (project initialization)
    - 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:
      - 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`):
        ```

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

@@ -7,7 +7,7 @@ metadata:
   author: "github-spec-kit"
   source: "templates/commands/taskstoissues.md"
 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).
 
+## 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
 
 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]
 > 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_skills": true,
   "branch_numbering": "sequential",
+  "context_file": "CLAUDE.md",
   "here": true,
   "integration": "claude",
-  "preset": null,
   "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",
-  "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",
-  "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": {
-    ".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",
-  "version": "0.5.1.dev0",
+  "version": "0.8.12",
   "installed_at": "2026-04-28T01:07:03.130714+00:00",
   "files": {
     ".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/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/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 {
     param(
         [string]$Branch,
@@ -138,29 +148,175 @@ function Test-FeatureBranch {
         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}-') {
-        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 $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 {
     $repoRoot = Get-RepoRoot
     $currentBranch = Get-CurrentBranch
     $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]@{
         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:
 #   1. .specify/templates/overrides/
 #   2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -227,6 +398,7 @@ function Resolve-Template {
                 $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 }
                 }
@@ -266,3 +438,206 @@ function Resolve-Template {
     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
                     } else {
                         # 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) {
-                            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
                         }
                     }
@@ -346,7 +350,10 @@ if (-not $DryRun) {
     if (-not (Test-Path -PathType Leaf $specFile)) {
         $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
         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 {
             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
 $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
@@ -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
 $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
 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 {
     Write-Warning "Plan template not found"
     # 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]
 **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.
   
-  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
   - Feature requirements from spec.md
   - Technical context from plan.md

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

@@ -1,9 +1,10 @@
 # Implementation Plan: [FEATURE]
 
 **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
+
 **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
 
@@ -17,14 +18,22 @@
   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]
-**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]
 
 ## Constitution Check
@@ -39,12 +48,12 @@
 
 ```text
 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)

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

@@ -1,8 +1,11 @@
 # Feature Specification: [FEATURE NAME]
 
-**Feature Branch**: `[###-feature-name]`  
-**Created**: [DATE]  
-**Status**: Draft  
+**Feature Branch**: `[###-feature-name]`
+
+**Created**: [DATE]
+
+**Status**: Draft
+
 **Input**: User description: "$ARGUMENTS"
 
 ## User Scenarios & Testing *(mandatory)*
@@ -11,7 +14,7 @@
   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,
   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.
   Think of each story as a standalone slice of functionality that can be:
   - Developed independently
@@ -85,7 +88,7 @@
 ### Functional Requirements
 
 - **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-004**: System MUST [data requirement, e.g., "persist user preferences"]
 - **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]
 
 **Input**: Design documents from `/specs/[###-feature-name]/`
+
 **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.
@@ -25,21 +26,21 @@ description: "Task list template for feature implementation"
 - **Mobile**: `api/src/`, `ios/src/` or `android/src/`
 - Paths shown below assume single project - adjust based on plan.md structure
 
-<!-- 
+<!--
   ============================================================================
   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...)
   - Feature requirements from plan.md
   - Entities from data-model.md
   - Endpoints from contracts/
-  
+
   Tasks MUST be organized by user story so each story can be:
   - Implemented independently
   - Tested independently
   - Delivered as an MVP increment
-  
+
   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
 
@@ -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.
+
+<!-- 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));
     }
+
+    /**
+     * 修改促销活动(仅未开始)
+     */
+    @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 org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * 商家端优惠券管理
@@ -72,6 +74,11 @@ public class ShPromotionCouponController extends BaseController
     @PostMapping
     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();
         return toAjax(promotionCouponBatchService.createBatch(batch, rule));
     }
@@ -86,4 +93,32 @@ public class ShPromotionCouponController extends BaseController
     {
         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 io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
-import net.sf.jsqlparser.expression.LongValue;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.RequestHeader;
 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 org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 /**
  * PromotionActivity对象 promotion_activity
@@ -37,39 +35,31 @@ public class PromotionActivity
     private Long id;
 
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
 
     /** 类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 */
-    @Excel(name = "类型")
     private Integer type;
 
     /** 活动名称 */
-    @Excel(name = "活动名称")
     private String name;
 
     /** 0=未开始 1=进行中 2=已结束 */
-    @Excel(name = "状态")
     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;
 
     /** 结束时间 */
-    @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;
 
     /** 创建时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
 
     /** 更新时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
 
     /** 活动规则 */
@@ -184,4 +174,4 @@ public class PromotionActivity
             .append("updateTime", getUpdateTime())
             .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 org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 /**
  * PromotionActivityRule对象 promotion_activity_rule
@@ -36,27 +34,21 @@ public class PromotionActivityRule
     private Long id;
 
     /** 关联促销活动 */
-    @Excel(name = "关联促销活动")
     private Long activityId;
 
     /** 商品ID */
-    @Excel(name = "商品ID")
     private Long productId;
 
     /** 满减门槛 */
-    @Excel(name = "满减门槛")
     private BigDecimal threshold;
 
     /** 减免金额 */
-    @Excel(name = "减免金额")
     private BigDecimal reduceAmount;
 
     /** 折扣率 */
-    @Excel(name = "折扣率")
     private BigDecimal discountRate;
 
     /** 最低数量 */
-    @Excel(name = "最低数量")
     private Integer minQuantity;
 
     /** 商品名称 */
@@ -153,4 +145,4 @@ public class PromotionActivityRule
             .append("minQuantity", getMinQuantity())
             .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 org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
+import java.util.List;
 
 /**
  * PromotionCouponBatch对象 promotion_coupon_batch
@@ -36,61 +35,53 @@ public class PromotionCouponBatch
     private Long id;
 
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
 
     /** 券名称 */
-    @Excel(name = "券名称")
     private String name;
 
-    /** 1=满减券 2=商品券 */
-    @Excel(name = "券类型")
+    /** 1=满减券 2=商品券 3=免配送费券 */
     private Integer couponType;
 
     /** 发放总量 */
-    @Excel(name = "发放总量")
     private Integer totalCount;
 
     /** 剩余数量 */
-    @Excel(name = "剩余数量")
     private Integer remainCount;
 
     /** 已领取数量 */
-    @Excel(name = "已领取数量")
     private Integer receivedCount;
 
     /** 0=未开始 1=进行中 2=已结束 3=已下架 */
-    @Excel(name = "状态")
     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;
 
     /** 结束时间 */
-    @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;
 
     /** 有效天数 */
-    @Excel(name = "有效天数")
     private Integer validDays;
 
     /** 创建时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
 
     /** 更新时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
 
-    /** 券规则 */
+    /** 券规则(单条,兼容旧接口) */
     @TableField(exist = false)
     private PromotionCouponRule rule;
 
+    /** 券规则列表(多商品券) */
+    @TableField(exist = false)
+    private List<PromotionCouponRule> rules;
+
     /** 是否可编辑 */
     @TableField(exist = false)
     private boolean editable;
@@ -243,4 +234,4 @@ public class PromotionCouponBatch
             .append("updateTime", getUpdateTime())
             .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 org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 /**
  * PromotionCouponRule对象 promotion_coupon_rule
@@ -36,27 +34,21 @@ public class PromotionCouponRule
     private Long id;
 
     /** 批次ID */
-    @Excel(name = "批次ID")
     private Long batchId;
 
     /** 商品ID */
-    @Excel(name = "商品ID")
     private Long productId;
 
     /** 0=同享 1=互斥 */
-    @Excel(name = "互斥状态")
     private Integer isMutex;
 
     /** 门槛金额 */
-    @Excel(name = "门槛金额")
     private BigDecimal threshold;
 
     /** 券面额 */
-    @Excel(name = "券面额")
     private BigDecimal amount;
 
     /** 折扣率 */
-    @Excel(name = "折扣率")
     private BigDecimal discountRate;
 
     /** 商品名称 */
@@ -153,4 +145,4 @@ public class PromotionCouponRule
             .append("discountRate", getDiscountRate())
             .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 org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
-import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import javax.persistence.GeneratedValue;
-import com.ruoyi.common.core.domain.BaseEntity;
 
 /**
  * PromotionUserCoupon对象 promotion_user_coupon
@@ -36,40 +34,40 @@ public class PromotionUserCoupon
     private Long id;
 
     /** 用户ID */
-    @Excel(name = "用户ID")
     private Long userId;
 
     /** 批次ID */
-    @Excel(name = "批次ID")
     private Long batchId;
 
     /** 门店ID */
-    @Excel(name = "门店ID")
     private Long storeId;
 
     /** 0=未使用 1=已使用 2=已过期 3=冻结 */
-    @Excel(name = "状态")
     private Integer status;
 
     /** 订单ID */
-    @Excel(name = "订单ID")
     private Long orderId;
 
     /** 领取时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "领取时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date receiveTime;
 
     /** 使用时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "使用时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date useTime;
 
     /** 过期时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "过期时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
     private Date expireTime;
 
+    /** 优惠券名称 */
+    @TableField(exist = false)
+    private String couponName;
+
+    /** 门店名称 */
+    @TableField(exist = false)
+    private String storeName;
+
     public void setId(Long id)
     {
         this.id = id;
@@ -174,4 +172,4 @@ public class PromotionUserCoupon
             .append("expireTime", getExpireTime())
             .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;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionActivity;
 
 /**
- * PromotionActivityMapper接口
+ * 促销活动Mapper接口
  *
  * @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;
 
 /**
- * PromotionActivityRuleMapper接口
+ * 促销活动规则Mapper接口
  *
  * @author ruoyi
- * @date 2024-05-30
  */
-public interface PromotionActivityRuleMapper extends BaseMapper<PromotionActivityRule>
-{
+public interface PromotionActivityRuleMapper extends BaseMapper<PromotionActivityRule> {
+
     /**
      * 根据活动ID查询规则列表
      *
      * @param activityId 活动ID
      * @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;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 
 /**
- * PromotionCouponBatchMapper接口
+ * 优惠券批次Mapper接口
  *
  * @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.ruoyi.system.domain.PromotionCouponRule;
+import java.util.List;
 
 /**
- * PromotionCouponRuleMapper接口
+ * 优惠券规则Mapper接口
  *
  * @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
-     * @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;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PromotionUserCoupon;
 
 /**
- * PromotionUserCouponMapper接口
+ * 用户优惠券Mapper接口
  *
  * @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);
 
+    /**
+     * 修改促销活动(仅未开始)
+     *
+     * @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.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponRule;
+import java.util.List;
 
 /**
  * 优惠券批次Service接口
@@ -21,6 +22,11 @@ public interface IPromotionCouponBatchService extends IService<PromotionCouponBa
      */
     boolean createBatch(PromotionCouponBatch batch, PromotionCouponRule rule);
 
+    /**
+     * 创建优惠券批次(含多条规则,多商品券)
+     */
+    boolean createBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules);
+
     /**
      * 查询批次详情(含规则)
      *
@@ -36,4 +42,26 @@ public interface IPromotionCouponBatchService extends IService<PromotionCouponBa
      * @return 是否成功
      */
     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.stereotype.Service;
 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.core.mapper.BaseMapper;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.mapper.PromotionActivityMapper;
 import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
 import com.ruoyi.system.domain.PromotionActivity;
@@ -47,6 +49,15 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         {
             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);
         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
     public boolean endActivity(Long id)
     {
+        PromotionActivity existing = promotionActivityMapper.selectById(id);
+        if (existing == null || existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能结束进行中的活动");
+        }
         PromotionActivity activity = new PromotionActivity();
         activity.setId(id);
         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;
 
 import java.util.Date;
+import java.util.List;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 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.PromotionCouponRuleMapper;
 import com.ruoyi.system.domain.PromotionCouponBatch;
@@ -57,6 +60,37 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
         }
         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);
             batch.setRule(rule);
+            List<PromotionCouponRule> rules = ruleMapper.selectRulesByBatchId(id);
+            batch.setRules(rules);
         }
         return batch;
     }
 
     /**
-     * 下架批次
+     * 下架批次(仅进行中)
      */
     @Override
     public boolean offShelfBatch(Long id)
     {
+        PromotionCouponBatch existing = promotionCouponBatchMapper.selectById(id);
+        if (existing == null || existing.getStatus() != 1)
+        {
+            throw new ServiceException("只能下架进行中的优惠券");
+        }
         PromotionCouponBatch batch = new PromotionCouponBatch();
         batch.setId(id);
         batch.setStatus(3);
         batch.setUpdateTime(new Date());
         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" ?>
 <!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">
 
     <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>
 
-</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
     </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>

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

@@ -20,4 +20,11 @@
         <result property="updateTime"    column="update_time"    />
     </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>

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

@@ -29,4 +29,15 @@
         LIMIT 1
     </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>

+ 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
 
-为 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
 
 **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
-**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 + 前端页面验证)
 **Target Platform**: 商家 PC 端管理后台 (foodie-store)
 **Project Type**: Web 应用 (Java 后端 + Vue 前端)
 **Performance Goals**: 无特殊性能要求,标准 CRUD 操作
 **Constraints**: 旧代码不动,新表用 promotion_ 前缀,SQL 写入 updatesql/sql.md 手动执行,前端文件 CRLF 换行
-**Scale/Scope**: 商家端 2 个新页面 + 后端 5 个实体 + 2 个 Controller
+**Scale/Scope**: 商家端 2 个新页面 + 后端 6 个实体 + 4 个 Controller (2 商家端 + 2 用户端)
 
 ## Constitution Check
 
@@ -77,6 +77,10 @@ ruoyi-admin/src/main/java/com/ruoyi/app/mendian/
 ├── ShPromotionActivityController.java    # 商家促销活动 API
 └── ShPromotionCouponController.java      # 商家优惠券 API
 
+ruoyi-admin/src/main/java/com/ruoyi/app/user/
+├── UserPromotionCouponController.java    # 用户端优惠券接口(领券、查券)
+└── UserPromotionCalcController.java      # 用户端算价接口(计算优惠)
+
 updatesql/
 └── sql.md                                # SQL 迁移脚本(追加建表语句)
 
@@ -103,21 +107,42 @@ src/
 
 ## 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. 活动状态管理
 
 活动状态使用显式字段(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/{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:"操作成功" }` |
+| 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:"操作成功" }` |
 
 ### ShPromotionCouponController
@@ -150,8 +177,110 @@ src/
 | 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: {...} } }` |
 | 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:"操作成功" }` |
 
+### 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
 
 **创建满减活动**:
@@ -206,7 +335,7 @@ src/
 
 ## 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 |
 |-----------|------------|-------------------------------------|
-| 折扣类型前端分组概念 | 商家需要按折扣率分组管理商品,类似美团交互 | 直接输入每条规则太繁琐,不符合商家操作习惯 |
+| 折扣类型前端分组概念(已废弃→改为逐商品设折扣) | 原设计按折扣率分组管理商品,参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担,美团实际不使用分组 |
 | 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)— 促销管理页面 + 优惠券管理页面
 
-用户端(小程序)的领券、下单优惠计算不在本 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 Story 1 - 商家创建促销活动 (Priority: P1)
 
-商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。
+商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。未开始的活动可以修改和删除,参考美团商家端交互。
 
 **Why this priority**: 促销是商家最核心的营销需求,没有促销就没有优惠,后续优惠券也无法叠加使用。
 
 **Independent Test**: 商家创建一个满减活动(满20减5),用户下单满20元后自动减5元,订单金额正确。
 
+**操作权限矩阵(参考美团)**:
+
+| 活动状态 | 编辑 | 删除 | 结束 |
+|----------|------|------|------|
+| 未开始(0) | ✅ | ✅ | ❌ |
+| 进行中(1) | ❌ | ❌ | ✅ |
+| 已结束(2) | ❌ | ❌ | ❌ |
+
 **Acceptance Scenarios**:
 
 1. **Given** 商家未创建任何促销活动, **When** 商家创建满减活动(满20减5 / 满40减12), **Then** 活动列表显示进行中,用户端店铺显示满减标签
 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,自动选满减
 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)
 
-商家创建优惠券(满减券/商品券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。
+商家创建优惠券(满减券/商品券/免配送费券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。未开始和进行中的优惠券可以修改,未开始的可以删除。
 
 **Why this priority**: 优惠券在促销基础上进一步促进复购和转化,但依赖促销系统先完成。
 
 **Independent Test**: 商家创建"满30减5"优惠券100张,用户进店领取,下单满30元时选择使用,实付减5元。
 
+**操作权限矩阵(参考美团)**:
+
+| 券状态 | 编辑 | 删除 | 下架 |
+|--------|------|------|------|
+| 未开始(0) | ✅ | ✅ | ❌ |
+| 进行中(1) | ✅ | ❌ | ✅ |
+| 已结束(2) | ❌ | ❌ | ❌ |
+| 已下架(3) | ❌ | ❌ | ❌ |
+
 **Acceptance Scenarios**:
 
 1. **Given** 商家创建满减券(同享券,满30减5,100张), **When** 用户进店看到领券区并领取, **Then** 用户"我的优惠券"列表显示该券,状态为未使用
@@ -151,8 +172,46 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - 用户领券后商家修改/下架了该券批次 → 已领取的券仍可使用(按领取时的规则),但不能再领取新券
 - 满减活动设了3个档位,订单金额正好在两个档位之间 → 命中低档位(如¥39命中满20减5而非满40减12)
 - 第二份半价商品用户只买了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
 
@@ -161,46 +220,51 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 #### 促销活动管理(商家端)
 
 - **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-004**: 商家 MUST 能创建新客立减活动,设置固定减免金额
+- **FR-004**: 商家 MUST 能创建新客立减活动,设置固定减免金额。「新客」定义为:该门店首次下单的用户(该门店无已完成订单记录)
 - **FR-005**: 商家 MUST 能查看活动列表(进行中/未开始/已结束),能结束进行中的活动
+- **FR-005a**: 商家 MUST 能修改未开始的活动(编辑活动名称、时间、规则),复用创建对话框回填数据
+- **FR-005b**: 商家 MUST 能删除未开始的活动(物理删除活动及关联规则),删除前需二次确认
 - **FR-006**: 系统 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**: 优惠券发放方式只有一种:店内领券(用户进店时在领券区领取)
 
-#### 用户端
+#### 用户端接口(后端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 同时计算两条路径:
   - 路径A(走折扣):折扣商品按折扣价,非折扣商品原价,再扣券
   - 路径B(走满减):所有商品恢复原价,扣满减,再扣券
 - **FR-018**: 系统 MUST 默认选择实付金额更低的路径
-- **FR-019**: 用户 MUST 能在结算页手动切换路径
-- **FR-020**: 同享券 MUST 能与满减/折扣叠加使用
+- **FR-019**: 用户 MUST 能在结算页手动切换路径 → 算价接口支持指定路径参数
+- **FR-020**: 同享券 MUST 能与满减/折扣叠加使用。满减券的 threshold 基于促销后金额判断
 - **FR-021**: 互斥券 MUST 不能与满减/折扣/第二份半价叠加
-- **FR-022**: 一个订单最多使用 1个促销 + 1张满减券 + 1张平台券
+- **FR-022**: 一个订单最多使用 1个促销 + 1张满减券。商品券对应的商品如正在参加折扣/第二份半价活动,该券不可用于该商品
 
 ### 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
 
@@ -245,7 +309,7 @@ CREATE TABLE promotion_coupon_batch (
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   store_id          BIGINT       NOT NULL COMMENT '门店ID',
   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 '发放总量',
   remain_count      INT          NOT NULL COMMENT '剩余数量',
   received_count    INT          DEFAULT 0 COMMENT '已领取数量',
@@ -266,8 +330,8 @@ CREATE TABLE promotion_coupon_rule (
   id                BIGINT AUTO_INCREMENT PRIMARY KEY,
   batch_id          BIGINT   NOT NULL COMMENT '关联券批次',
   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 '减免金额(满减券/抵用券)',
   discount_rate     DECIMAL(3,2)  DEFAULT NULL COMMENT '折扣率(商品折扣券用)',
   INDEX idx_batch (batch_id)
@@ -294,7 +358,7 @@ CREATE TABLE promotion_user_coupon (
 
 ### pos_order_promotion — 订单优惠明细表
 
-下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。
+下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。由算价接口在下单时写入。
 
 ```sql
 CREATE TABLE pos_order_promotion (
@@ -376,7 +440,7 @@ CREATE TABLE pos_order_promotion (
 ### Measurable Outcomes
 
 - **SC-001**: 商家能在3分钟内完成一个满减活动的创建(包括设置3个档位)
-- **SC-002**: 商家能在3分钟内完成一个分组折扣活动的创建(建2个档位 + 分配5个商品
+- **SC-002**: 商家能在3分钟内完成一个折扣活动的创建(选择5个商品 + 逐个设折扣率
 - **SC-003**: 商家能在2分钟内完成一张优惠券的创建
 - **SC-004**: 用户下单时优惠计算正确,结算页实时展示优惠明细
 - **SC-005**: 满100%覆盖互斥规则:满减/折扣/第二份半价三选一,新客立减可叠加
@@ -385,11 +449,11 @@ CREATE TABLE pos_order_promotion (
 
 - **旧代码不动**:`SalesPromotion`、`VipQuanyi`、`VipUserQuanyi` 等旧实体和旧接口全部废弃,新功能完全新建文件
 - **新表前缀 `promotion_`**:与旧表(`sales_promotion`、`vip_quanyi`)完全独立
-- **工作范围**:只有后端 API + 商家 PC 端前端(foodie-store)
-- **用户端不在本范围**:小程序端的领券、下单优惠计算、结算页展示后续单独做
+- **工作范围**:后端 API(商家端管理接口 + 用户端接口)+ 商家 PC 端前端(foodie-store)
+- **用户端前端不在本范围**:小程序端的领券页面、结算页展示后续单独做,但后端接口(FR-011~022)本期实施
 - 促销和优惠券都是门店级别(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`,由开发者手动执行
 
@@ -401,3 +465,4 @@ CREATE TABLE pos_order_promotion (
 - 优惠券系统设计(知乎)
 - 大厂优惠券系统设计(掘金)
 - 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**: 创建数据库表,为后续所有任务提供基础
 
-- [ ] 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个文件,全部可并行)
 
-- [ ] 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
 
@@ -60,29 +63,30 @@
 
 ### 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)
 
-- [ ] 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)
 
-- [ ] 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)
 
-- [ ] 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
   - **创建对话框** el-dialog width=750px + el-tabs v-model=activeTab:
     - 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 + 选择商品按钮
     - Tab4 新客立减(type=4): 活动名称 + 时间范围 + 减免金额el-input-number
   - **商品选择弹窗**: 分类el-select + 搜索el-input + 商品el-table + el-pagination (复用Quanyi.vue商品加载API模式)
   - **提交逻辑** tijiaobaocun(): 根据activeTab转换数据为 {storeId,type,name,startTime,endTime,rules:[...]},折扣组展开为扁平rules,调用addPromotionActivity
+  - **互斥提示**: 创建对话框顶部展示互斥规则提示文案(FR-006):"满减、折扣、第二份半价三选一(系统自动选最优),新客立减可叠加"
   - **i18n**: 所有文字用 $t('promoActivity.xxx')
 
 **Checkpoint**: 促销活动完整功能可用——创建4种类型活动、查看列表、查看详情、结束活动
@@ -97,21 +101,21 @@
 
 ### 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)
 
-- [ ] 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)
 
-- [ ] 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)
 
-- [ ] 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-dialog width=600px:
     - 券类型 el-radio-group (满减券/商品券)
@@ -131,23 +135,185 @@
 
 > **注意**: 前端文件使用 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**: 侧边栏显示新菜单,页面可访问,多语言正常
 
 ---
 
+## 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 & 验证
 
 **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种语言验证所有文案显示正确
 
 ---
@@ -161,23 +327,27 @@
 - **Phase 3 (US1 促销)**: 依赖 Phase 2 的实体和 Mapper
 - **Phase 4 (US2 优惠券)**: 依赖 Phase 2,可与 Phase 3 并行
 - **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
 
-- 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)
+- T039 和 T040 可并行(不同 Service)
 - T032-T035 (4个语言文件) 可并行
 
 ### Recommended Execution Order
 
 ```
 T001 → T002(手动)
-  → T003-T017 (Phase 2, 大量并行)
+  → T003-T017a (Phase 2, 大量并行)
   → T018-T023 (Phase 3, 促销活动) + T024-T029 (Phase 4, 优惠券) [可并行]
   → T030-T035 (Phase 5, 菜单路由i18n)
+  → T039-T045 (Phase 5.5, 用户端接口)
+  → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣) ← 当前待实施
   → T036-T038 (Phase 6, 验证)
 ```
 
@@ -190,4 +360,6 @@ T001 → T002(手动)
 - 每个任务完成后 commit
 - 前端文件 CRLF 换行,用 Python 脚本编辑
 - 旧代码(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)
 ) 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,仅记录)
+```