#feature: update SQL generator
This commit is contained in:
Binary file not shown.
+124
-38
@@ -1,17 +1,10 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: php
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
@@ -19,50 +12,143 @@ ignored_paths: []
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
#
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `activate_project`: Activates a project based on the project name or path.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||
# for example by saying that the information retrieved from a memory file is no longer correct
|
||||
# or no longer relevant for the project.
|
||||
# * `edit_memory`: Replaces content matching a regular expression in a memory.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `find_file`: Finds files in the given relative paths
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
|
||||
# * `find_symbol`: Performs a global (or local) search using the language server backend.
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
|
||||
# for clients that do not read the initial instructions when the MCP server is connected.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
|
||||
# is relevant to the current task. You can infer whether the information
|
||||
# is relevant from the memory file name.
|
||||
# You should not read the same memory file multiple times in the same conversation.
|
||||
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
|
||||
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
|
||||
# For JB, we use a separate tool.
|
||||
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
|
||||
# * `safe_delete_symbol`:
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||
# The memory name should be meaningful.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "toolbox"
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: utf-8
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# haxe java julia kotlin lua
|
||||
# markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- php
|
||||
|
||||
@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\JiraService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class JiraController extends Controller
|
||||
{
|
||||
private const WEEKLY_REPORT_PERIODS = ['this_week', 'last_week'];
|
||||
|
||||
private JiraService $jiraService;
|
||||
|
||||
public function __construct(JiraService $jiraService)
|
||||
@@ -16,37 +18,44 @@ class JiraController extends Controller
|
||||
$this->jiraService = $jiraService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 生成上周周报
|
||||
* 生成周报
|
||||
*/
|
||||
public function generateWeeklyReport(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$username = $request->input('username') ?: config('jira.default_user');
|
||||
$period = $request->input('period', 'this_week');
|
||||
|
||||
if (!$username) {
|
||||
if (! $username) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供用户名'
|
||||
'message' => '请提供用户名',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username);
|
||||
if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '无效的周报周期',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username, $period);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'report' => $report,
|
||||
'username' => $username,
|
||||
'generated_at' => Carbon::now()->format('Y-m-d H:i:s')
|
||||
]
|
||||
'period' => $period,
|
||||
'generated_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '生成周报失败: ' . $e->getMessage()
|
||||
'message' => '生成周报失败: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -77,14 +86,14 @@ class JiraController extends Controller
|
||||
'total_records' => $workLogs->count(),
|
||||
'date_range' => [
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $endDate->format('Y-m-d')
|
||||
]
|
||||
]
|
||||
'end' => $endDate->format('Y-m-d'),
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取工时记录失败: ' . $e->getMessage()
|
||||
'message' => '获取工时记录失败: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -98,8 +107,8 @@ class JiraController extends Controller
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'default_user' => config('jira.default_user', ''),
|
||||
'host' => config('jira.host', '')
|
||||
]
|
||||
'host' => config('jira.host', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -110,26 +119,33 @@ class JiraController extends Controller
|
||||
{
|
||||
try {
|
||||
$username = $request->input('username') ?: config('jira.default_user');
|
||||
$period = $request->input('period', 'this_week');
|
||||
|
||||
if (!$username) {
|
||||
if (! $username) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供用户名'
|
||||
'message' => '请提供用户名',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username);
|
||||
$filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d'));
|
||||
if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '无效的周报周期',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username, $period);
|
||||
$filename = sprintf('weekly_report_%s_%s_%s.md', $username, $period, Carbon::now()->format('Y-m-d'));
|
||||
|
||||
return response($report)
|
||||
->header('Content-Type', 'text/markdown')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '下载周报失败: ' . $e->getMessage()
|
||||
'message' => '下载周报失败: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class SqlGeneratorController extends Controller
|
||||
if (empty($caseCodes)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供有效的 case_id 列表'
|
||||
'message' => '请提供有效的 case_id 列表',
|
||||
], 400);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,212 @@ class SqlGeneratorController extends Controller
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '查询 case_extras 失败: ' . $e->getMessage(),
|
||||
'message' => '查询 case_extras 失败: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 CRM 加工单关联地址国家,用于区分 PP-CN / PP-US。
|
||||
*/
|
||||
public function checkProductionCountries(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'production_codes' => 'required|array|min:1',
|
||||
'production_codes.*' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$productionCodes = array_values(array_unique(array_filter(array_map('trim', $request->input('production_codes')))));
|
||||
|
||||
if (empty($productionCodes)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供有效的加工单列表',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$productionCountries = $this->getProductionCountries($productionCodes);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'production_countries' => $productionCountries,
|
||||
],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '查询 CRM 加工单国家失败: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function getProductionCountries(array $productionCodes): array
|
||||
{
|
||||
$productionCountries = [];
|
||||
|
||||
foreach (array_chunk($productionCodes, 1000) as $chunk) {
|
||||
$productions = DB::connection('crmslave')
|
||||
->table('ea_production as ep')
|
||||
->join('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
|
||||
->where('ep.deleted', 0)
|
||||
->whereIn('ep.name', $chunk)
|
||||
->select([
|
||||
'ep.name as production_code',
|
||||
'epc.ea_case_id_c',
|
||||
'epc.ea_businessorder_id_c',
|
||||
'epc.ea_salesorder_id_c',
|
||||
])
|
||||
->get();
|
||||
|
||||
$caseIds = $productions->pluck('ea_case_id_c')->filter()->unique()->values()->all();
|
||||
$businessOrderIds = $productions->pluck('ea_businessorder_id_c')->filter()->unique()->values()->all();
|
||||
$salesOrderIds = $productions->pluck('ea_salesorder_id_c')->filter()->unique()->values()->all();
|
||||
|
||||
$caseCountries = $this->getCountriesByCaseIds($caseIds);
|
||||
$businessOrderCountries = $this->getCountriesByBusinessOrderIds($businessOrderIds);
|
||||
$salesOrderCountries = $this->getCountriesBySalesOrderIds($salesOrderIds);
|
||||
|
||||
foreach ($productions as $production) {
|
||||
$countries = array_merge(
|
||||
$caseCountries[(string) $production->ea_case_id_c] ?? [],
|
||||
$businessOrderCountries[(string) $production->ea_businessorder_id_c] ?? [],
|
||||
$salesOrderCountries[(string) $production->ea_salesorder_id_c] ?? []
|
||||
);
|
||||
|
||||
$productionCountries[(string) $production->production_code] = array_values(array_unique(array_filter($countries)));
|
||||
}
|
||||
}
|
||||
|
||||
return $productionCountries;
|
||||
}
|
||||
|
||||
private function getCountriesByCaseIds(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = DB::connection('crmslave')
|
||||
->table('ea_case as ec')
|
||||
->join('accounts_ea_case_1_c as aec1c', function ($join) {
|
||||
$join->on('aec1c.accounts_ea_case_1ea_case_idb', '=', 'ec.id')
|
||||
->where('aec1c.deleted', '=', 0);
|
||||
})
|
||||
->join('accounts as a', 'a.id', '=', 'aec1c.accounts_ea_case_1accounts_ida')
|
||||
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
|
||||
->where('ec.deleted', 0)
|
||||
->where('a.deleted', 0)
|
||||
->whereIn('ec.id', $caseIds)
|
||||
->whereNotNull('ac.country_c')
|
||||
->select([
|
||||
'ec.id as entity_id',
|
||||
'ac.country_c as country',
|
||||
'ac.province_c as province',
|
||||
])
|
||||
->get();
|
||||
|
||||
return $this->groupCountriesByEntityId($results);
|
||||
}
|
||||
|
||||
private function getCountriesByBusinessOrderIds(array $businessOrderIds): array
|
||||
{
|
||||
if (empty($businessOrderIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = DB::connection('crmslave')
|
||||
->table('ea_businessorder as eb')
|
||||
->join('accounts_ea_businessorder_1_c as aeb1c', function ($join) {
|
||||
$join->on('aeb1c.accounts_ea_businessorder_1ea_businessorder_idb', '=', 'eb.id')
|
||||
->where('aeb1c.deleted', '=', 0);
|
||||
})
|
||||
->join('accounts as a', 'a.id', '=', 'aeb1c.accounts_ea_businessorder_1accounts_ida')
|
||||
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
|
||||
->where('eb.deleted', 0)
|
||||
->where('a.deleted', 0)
|
||||
->whereIn('eb.id', $businessOrderIds)
|
||||
->whereNotNull('ac.country_c')
|
||||
->select([
|
||||
'eb.id as entity_id',
|
||||
'ac.country_c as country',
|
||||
'ac.province_c as province',
|
||||
])
|
||||
->get();
|
||||
|
||||
return $this->groupCountriesByEntityId($results);
|
||||
}
|
||||
|
||||
private function getCountriesBySalesOrderIds(array $salesOrderIds): array
|
||||
{
|
||||
if (empty($salesOrderIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = DB::connection('crmslave')
|
||||
->table('ea_salesorder as es')
|
||||
->join('accounts_ea_salesorder_1_c as aes1c', function ($join) {
|
||||
$join->on('aes1c.accounts_ea_salesorder_1ea_salesorder_idb', '=', 'es.id')
|
||||
->where('aes1c.deleted', '=', 0);
|
||||
})
|
||||
->join('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
|
||||
->join('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
|
||||
->where('es.deleted', 0)
|
||||
->where('a_base.deleted', 0)
|
||||
->whereIn('es.id', $salesOrderIds)
|
||||
->whereNotNull('ac.country_c')
|
||||
->select([
|
||||
'es.id as entity_id',
|
||||
'ac.country_c as country',
|
||||
'ac.province_c as province',
|
||||
])
|
||||
->get();
|
||||
|
||||
return $this->groupCountriesByEntityId($results);
|
||||
}
|
||||
|
||||
private function groupCountriesByEntityId($results): array
|
||||
{
|
||||
$countriesByEntityId = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$countryCode = $this->getCountryCode($result->country, $result->province);
|
||||
if (! $countryCode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityId = (string) $result->entity_id;
|
||||
$countriesByEntityId[$entityId] ??= [];
|
||||
$countriesByEntityId[$entityId][] = $countryCode;
|
||||
}
|
||||
|
||||
return array_map(fn ($countries) => array_values(array_unique($countries)), $countriesByEntityId);
|
||||
}
|
||||
|
||||
private function getCountryCode(?string $country, ?string $province): ?string
|
||||
{
|
||||
if (! $country) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($country, ['1', '156'], true) && ! in_array((string) $province, ['710000', '810000', '820000'], true)) {
|
||||
return 'CN';
|
||||
}
|
||||
|
||||
return [
|
||||
'840' => 'US',
|
||||
'US' => 'US',
|
||||
'316' => 'GU',
|
||||
'GU' => 'GU',
|
||||
'630' => 'PR',
|
||||
'PR' => 'PR',
|
||||
][strtoupper($country)] ?? strtoupper($country);
|
||||
}
|
||||
}
|
||||
|
||||
+115
-71
@@ -2,17 +2,19 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use JiraRestApi\Configuration\ArrayConfiguration;
|
||||
use JiraRestApi\Issue\IssueService;
|
||||
use JiraRestApi\JiraException;
|
||||
use JiraRestApi\Project\ProjectService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class JiraService
|
||||
{
|
||||
private IssueService $issueService;
|
||||
|
||||
private ProjectService $projectService;
|
||||
|
||||
private array $config;
|
||||
|
||||
public function __construct()
|
||||
@@ -39,7 +41,6 @@ class JiraService
|
||||
$this->projectService = new ProjectService($clientConfig);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 按项目组织任务数据
|
||||
*/
|
||||
@@ -51,7 +52,7 @@ class JiraService
|
||||
$projectKey = $issue->fields->project->key;
|
||||
$isSubtask = $issue->fields->issuetype->subtask ?? false;
|
||||
|
||||
if (!$organized->has($projectKey)) {
|
||||
if (! $organized->has($projectKey)) {
|
||||
$organized->put($projectKey, [
|
||||
'name' => $issue->fields->project->name,
|
||||
'tasks' => collect(),
|
||||
@@ -81,7 +82,7 @@ class JiraService
|
||||
'summary',
|
||||
'status',
|
||||
'project',
|
||||
'issuetype'
|
||||
'issuetype',
|
||||
]);
|
||||
} catch (JiraException) {
|
||||
return null;
|
||||
@@ -93,14 +94,14 @@ class JiraService
|
||||
$tasks->put($issue->key, [
|
||||
'key' => $issue->key,
|
||||
'summary' => $issue->fields->summary,
|
||||
'url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'url' => $this->config['host'].'/browse/'.$issue->key,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function addSubtask(Collection $tasks, string $parentKey, $issue): void
|
||||
{
|
||||
if (!$tasks->has($parentKey)) {
|
||||
if (! $tasks->has($parentKey)) {
|
||||
// 获取父任务的真实信息
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务';
|
||||
@@ -108,7 +109,7 @@ class JiraService
|
||||
$tasks->put($parentKey, [
|
||||
'key' => $parentKey,
|
||||
'summary' => $parentSummary,
|
||||
'url' => $this->config['host'] . '/browse/' . $parentKey,
|
||||
'url' => $this->config['host'].'/browse/'.$parentKey,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
@@ -116,7 +117,7 @@ class JiraService
|
||||
$tasks[$parentKey]['subtasks']->put($issue->key, [
|
||||
'key' => $issue->key,
|
||||
'summary' => $issue->fields->summary,
|
||||
'url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'url' => $this->config['host'].'/browse/'.$issue->key,
|
||||
'created' => $issue->fields->created ?? null,
|
||||
]);
|
||||
}
|
||||
@@ -128,7 +129,7 @@ class JiraService
|
||||
{
|
||||
$username = $username ?: $this->config['default_user'];
|
||||
|
||||
if (!$username) {
|
||||
if (! $username) {
|
||||
throw new \InvalidArgumentException('用户名不能为空');
|
||||
}
|
||||
|
||||
@@ -153,14 +154,14 @@ class JiraService
|
||||
'status',
|
||||
'project',
|
||||
'issuetype',
|
||||
'created'
|
||||
'created',
|
||||
]);
|
||||
|
||||
if (!empty($issues->issues)) {
|
||||
if (! empty($issues->issues)) {
|
||||
return $this->organizeIssuesByProject($issues->issues);
|
||||
}
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取未来任务失败: ' . $e->getMessage());
|
||||
throw new \RuntimeException('获取未来任务失败: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return collect();
|
||||
@@ -169,24 +170,21 @@ class JiraService
|
||||
/**
|
||||
* 生成 Markdown 格式的周报
|
||||
*/
|
||||
public function generateWeeklyReport(?string $username = null): string
|
||||
public function generateWeeklyReport(?string $username = null, string $period = 'this_week'): string
|
||||
{
|
||||
$username = $username ?: $this->config['default_user'];
|
||||
|
||||
// 获取上周的工时记录
|
||||
$now = Carbon::now();
|
||||
$startOfWeek = $now->copy()->subWeek()->startOfWeek();
|
||||
$endOfWeek = $now->copy()->subWeek()->endOfWeek();
|
||||
$reportPeriod = $this->resolveWeeklyReportRange($period);
|
||||
|
||||
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek);
|
||||
$workLogs = $this->getWorkLogs($username, $reportPeriod['start'], $reportPeriod['end']);
|
||||
$organizedTasks = $this->organizeTasksForReport($workLogs, $username);
|
||||
|
||||
$nextWeekTasks = $this->getNextWeekTasks($username);
|
||||
|
||||
$markdown = "# 过去一周的任务\n\n";
|
||||
$markdown = "# {$reportPeriod['title']}\n\n";
|
||||
|
||||
if ($organizedTasks->isEmpty()) {
|
||||
$markdown .= "本周暂无工时记录的任务。\n\n";
|
||||
$markdown .= "{$reportPeriod['empty_message']}\n\n";
|
||||
} else {
|
||||
// 按Sprint分类的需求
|
||||
if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) {
|
||||
@@ -311,6 +309,33 @@ class JiraService
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析周报统计周期
|
||||
*
|
||||
* @return array{start: Carbon, end: Carbon, title: string, empty_message: string}
|
||||
*/
|
||||
private function resolveWeeklyReportRange(string $period): array
|
||||
{
|
||||
$normalizedPeriod = $period === 'this_week' ? 'this_week' : 'last_week';
|
||||
$now = Carbon::now();
|
||||
|
||||
if ($normalizedPeriod === 'this_week') {
|
||||
return [
|
||||
'start' => $now->copy()->startOfWeek(),
|
||||
'end' => $now->copy()->endOfDay(),
|
||||
'title' => '本周完成的任务',
|
||||
'empty_message' => '本周暂无工时记录的任务。',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'start' => $now->copy()->subWeek()->startOfWeek(),
|
||||
'end' => $now->copy()->subWeek()->endOfWeek(),
|
||||
'title' => '上周完成的任务',
|
||||
'empty_message' => '上周暂无工时记录的任务。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期范围内的工时记录
|
||||
*/
|
||||
@@ -345,7 +370,7 @@ class JiraService
|
||||
'customfield_14305', // 需求类型
|
||||
]);
|
||||
|
||||
if (!empty($issues->issues)) {
|
||||
if (! empty($issues->issues)) {
|
||||
$workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate);
|
||||
|
||||
if ($workLogs->isNotEmpty()) {
|
||||
@@ -353,8 +378,9 @@ class JiraService
|
||||
}
|
||||
}
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取工时记录失败: ' . $e->getMessage());
|
||||
throw new \RuntimeException('获取工时记录失败: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// 如果所有查询都没有结果,返回空集合
|
||||
return collect();
|
||||
}
|
||||
@@ -377,7 +403,7 @@ class JiraService
|
||||
// 处理 author 可能是数组或对象的情况
|
||||
$authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? '');
|
||||
|
||||
if (!empty($authorName) && $authorName === $username &&
|
||||
if (! empty($authorName) && $authorName === $username &&
|
||||
$worklogDate->between($startDate, $endDate)) {
|
||||
|
||||
// 获取父任务信息
|
||||
@@ -411,7 +437,7 @@ class JiraService
|
||||
'project_key' => $issue->fields->project->key ?? '',
|
||||
'issue_key' => $issue->key,
|
||||
'issue_summary' => $issue->fields->summary ?? '',
|
||||
'issue_url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'issue_url' => $this->config['host'].'/browse/'.$issue->key,
|
||||
'issue_status' => $issue->fields->status->name ?? 'Unknown',
|
||||
'issue_type' => $issue->fields->issuetype->name ?? 'Unknown',
|
||||
'issue_created' => $issue->fields->created ?? null,
|
||||
@@ -464,7 +490,7 @@ class JiraService
|
||||
$sprintField = $issue->fields->customFields['customfield_10004'];
|
||||
|
||||
// 处理数组情况
|
||||
if (is_array($sprintField) && !empty($sprintField)) {
|
||||
if (is_array($sprintField) && ! empty($sprintField)) {
|
||||
$lastSprint = end($sprintField);
|
||||
if (is_string($lastSprint)) {
|
||||
// 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...]
|
||||
@@ -486,13 +512,14 @@ class JiraService
|
||||
if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// 如果是纯文本,直接返回
|
||||
return $sprintField;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从fixVersions获取版本信息作为备选
|
||||
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && !empty($issue->fields->fixVersions)) {
|
||||
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && ! empty($issue->fields->fixVersions)) {
|
||||
return $issue->fields->fixVersions[0]->name ?? null;
|
||||
}
|
||||
|
||||
@@ -526,7 +553,7 @@ class JiraService
|
||||
$stageValue = null;
|
||||
}
|
||||
|
||||
if ($stageValue && !empty($stageValue)) {
|
||||
if ($stageValue && ! empty($stageValue)) {
|
||||
// 标准化阶段名称
|
||||
if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') {
|
||||
return 'SIT环境BUG';
|
||||
@@ -537,8 +564,9 @@ class JiraService
|
||||
if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) {
|
||||
return 'UAT环境BUG';
|
||||
}
|
||||
|
||||
// 如果不匹配标准格式,直接返回原值
|
||||
return $stageValue . 'BUG';
|
||||
return $stageValue.'BUG';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,7 +600,7 @@ class JiraService
|
||||
// 处理对象类型
|
||||
if (is_object($type) && isset($type->value)) {
|
||||
return $type->value;
|
||||
} elseif (is_string($type) && !empty($type)) {
|
||||
} elseif (is_string($type) && ! empty($type)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
@@ -601,7 +629,7 @@ class JiraService
|
||||
if (isset($issue->fields->customFields['customfield_10115'])) {
|
||||
$description = $issue->fields->customFields['customfield_10115'];
|
||||
|
||||
if (is_string($description) && !empty($description)) {
|
||||
if (is_string($description) && ! empty($description)) {
|
||||
return $description;
|
||||
}
|
||||
}
|
||||
@@ -617,7 +645,7 @@ class JiraService
|
||||
// 从customfield_14305获取需求类型
|
||||
if (isset($issue->fields->customFields['customfield_14305'])) {
|
||||
$type = $issue->fields->customFields['customfield_14305'];
|
||||
if (is_array($type) && !empty($type)) {
|
||||
if (is_array($type) && ! empty($type)) {
|
||||
$firstType = $type[0];
|
||||
if (is_object($firstType) && isset($firstType->value)) {
|
||||
return $firstType->value;
|
||||
@@ -657,16 +685,8 @@ class JiraService
|
||||
*/
|
||||
private function extractDeveloper($issue): ?string
|
||||
{
|
||||
// 从customfield_11000获取开发人
|
||||
if (isset($issue->fields->customFields['customfield_11000'])) {
|
||||
$developer = $issue->fields->customFields['customfield_11000'];
|
||||
|
||||
if (is_string($developer) && !empty($developer)) {
|
||||
return $developer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组)
|
||||
return $this->extractUserFieldName($issue, 'customfield_11000');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -674,13 +694,32 @@ class JiraService
|
||||
*/
|
||||
private function extractActualFixer($issue): ?string
|
||||
{
|
||||
// 从customfield_11301获取实际修复人
|
||||
if (isset($issue->fields->customFields['customfield_11301'])) {
|
||||
$fixer = $issue->fields->customFields['customfield_11301'];
|
||||
// 从customfield_11301获取实际修复人(User 类型字段返回对象,兼容字符串/数组)
|
||||
return $this->extractUserFieldName($issue, 'customfield_11301');
|
||||
}
|
||||
|
||||
if (is_string($fixer) && !empty($fixer)) {
|
||||
return $fixer;
|
||||
}
|
||||
/**
|
||||
* 通用 User 类型自定义字段解析:兼容对象、关联数组、字符串
|
||||
*/
|
||||
private function extractUserFieldName($issue, string $fieldKey): ?string
|
||||
{
|
||||
if (! isset($issue->fields->customFields[$fieldKey])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $issue->fields->customFields[$fieldKey];
|
||||
|
||||
if (is_object($value)) {
|
||||
return $value->name ?? $value->key ?? null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
// JIRA 用户字段以关联数组形式返回时
|
||||
return $value['name'] ?? $value['key'] ?? null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -695,6 +734,7 @@ class JiraService
|
||||
$summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary);
|
||||
// 移除多余的空格和换行
|
||||
$summary = preg_replace('/\s+/', ' ', $summary);
|
||||
|
||||
return trim($summary);
|
||||
}
|
||||
|
||||
@@ -709,6 +749,9 @@ class JiraService
|
||||
'未开始',
|
||||
'需求已确认',
|
||||
'开发中',
|
||||
'Open',
|
||||
'To Do',
|
||||
'In Progress',
|
||||
'需求调研中',
|
||||
'需求已调研',
|
||||
'需求已评审',
|
||||
@@ -718,7 +761,7 @@ class JiraService
|
||||
];
|
||||
|
||||
// 如果状态不在"未完成"列表中,则标记为已完成
|
||||
return !in_array($status, $incompleteStatuses, true);
|
||||
return ! in_array($status, $incompleteStatuses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,13 +818,13 @@ class JiraService
|
||||
|| ($actualFixer === $username);
|
||||
|
||||
// 如果不是当前用户相关的Bug,跳过
|
||||
if (!$isUserRelated) {
|
||||
if (! $isUserRelated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bug按发现阶段分类
|
||||
$stage = $workLog['bug_stage'];
|
||||
if (!$organized['bugs']->has($stage)) {
|
||||
if (! $organized['bugs']->has($stage)) {
|
||||
$organized['bugs']->put($stage, collect());
|
||||
}
|
||||
|
||||
@@ -804,15 +847,15 @@ class JiraService
|
||||
} elseif (($isStory || $isSubtask) && $workLog['sprint']) {
|
||||
// Story类型或子任务,且有Sprint的,按Sprint分类(需求)
|
||||
$sprintName = $workLog['sprint'];
|
||||
if (!$organized['sprints']->has($sprintName)) {
|
||||
if (! $organized['sprints']->has($sprintName)) {
|
||||
$organized['sprints']->put($sprintName, collect());
|
||||
}
|
||||
|
||||
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
|
||||
} elseif ($isStory && !$workLog['sprint']) {
|
||||
} elseif ($isStory && ! $workLog['sprint']) {
|
||||
// Story类型但没有Sprint的,放入需求分类
|
||||
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
|
||||
} elseif ($isSubtask && !$workLog['sprint'] && $workLog['parent_task']) {
|
||||
} elseif ($isSubtask && ! $workLog['sprint'] && $workLog['parent_task']) {
|
||||
// 子任务没有Sprint,检查父任务类型来决定分类
|
||||
$parentKey = $workLog['parent_task']['key'];
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
@@ -846,7 +889,7 @@ class JiraService
|
||||
// 子任务
|
||||
$parentKey = $workLog['parent_task']['key'];
|
||||
|
||||
if (!$taskList->has($parentKey)) {
|
||||
if (! $taskList->has($parentKey)) {
|
||||
// 获取父任务的真实信息
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary'];
|
||||
@@ -855,7 +898,7 @@ class JiraService
|
||||
$taskList->put($parentKey, [
|
||||
'key' => $parentKey,
|
||||
'summary' => $parentSummary,
|
||||
'url' => $this->config['host'] . '/browse/' . $parentKey,
|
||||
'url' => $this->config['host'].'/browse/'.$parentKey,
|
||||
'status' => $parentStatus,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
@@ -870,7 +913,7 @@ class JiraService
|
||||
]);
|
||||
} else {
|
||||
// 主任务
|
||||
if (!$taskList->has($workLog['issue_key'])) {
|
||||
if (! $taskList->has($workLog['issue_key'])) {
|
||||
$taskList->put($workLog['issue_key'], [
|
||||
'key' => $workLog['issue_key'],
|
||||
'summary' => $workLog['issue_summary'],
|
||||
@@ -886,15 +929,15 @@ class JiraService
|
||||
* 获取下一个 release 版本
|
||||
* 根据当前版本号,在 Jira 版本列表中找到下一个版本
|
||||
*
|
||||
* @param string $projectKey Jira 项目 key
|
||||
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt)
|
||||
* @param string $projectKey Jira 项目 key
|
||||
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt)
|
||||
*/
|
||||
public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array
|
||||
{
|
||||
try {
|
||||
$versions = $this->projectService->getVersions($projectKey);
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取 release 版本失败: ' . $e->getMessage(), previous: $e);
|
||||
throw new \RuntimeException('获取 release 版本失败: '.$e->getMessage(), previous: $e);
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
@@ -903,8 +946,8 @@ class JiraService
|
||||
|
||||
// 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0)
|
||||
$sortedVersions = collect($versions)
|
||||
->filter(fn($version) => !empty($version->name))
|
||||
->sortBy(fn($version) => $version->name, SORT_NATURAL)
|
||||
->filter(fn ($version) => ! empty($version->name))
|
||||
->sortBy(fn ($version) => $version->name, SORT_NATURAL)
|
||||
->values();
|
||||
|
||||
if ($sortedVersions->isEmpty()) {
|
||||
@@ -914,17 +957,17 @@ class JiraService
|
||||
// 如果没有提供当前版本,返回第一个未发布的版本
|
||||
if (empty($currentVersion)) {
|
||||
$candidate = $sortedVersions
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->filter(fn ($version) => ! ($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
if (! $candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
'release_date' => ! empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
@@ -932,7 +975,7 @@ class JiraService
|
||||
|
||||
// 找到当前版本在列表中的位置,返回下一个版本
|
||||
$currentIndex = $sortedVersions->search(
|
||||
fn($version) => $version->name === $currentVersion
|
||||
fn ($version) => $version->name === $currentVersion
|
||||
);
|
||||
|
||||
// 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本
|
||||
@@ -942,18 +985,19 @@ class JiraService
|
||||
if ($version->released ?? false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return version_compare($version->name, $currentVersion, '>');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
if (! $candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
'release_date' => ! empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
@@ -962,17 +1006,17 @@ class JiraService
|
||||
// 从当前版本的下一个开始,找到第一个未发布的版本
|
||||
$candidate = $sortedVersions
|
||||
->slice($currentIndex + 1)
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->filter(fn ($version) => ! ($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
if (! $candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
'release_date' => ! empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">生成周报</h1>
|
||||
<p class="text-gray-600 mt-2">生成上周的工作周报</p>
|
||||
<p class="text-gray-600 mt-2">按周选择统计范围,生成对应周期的工作周报</p>
|
||||
</div>
|
||||
|
||||
<!-- 周报生成区域 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成上周周报</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成{{ selectedPeriodLabel }}周报</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
@@ -20,6 +20,17 @@
|
||||
placeholder="输入 JIRA 用户名"
|
||||
>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">统计周期</label>
|
||||
<select
|
||||
v-model="weeklyReport.period"
|
||||
@change="resetWeeklyReportResult"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="last_week">上周</option>
|
||||
<option value="this_week">本周</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="generateWeeklyReport"
|
||||
@@ -73,6 +84,7 @@ export default {
|
||||
return {
|
||||
weeklyReport: {
|
||||
username: '',
|
||||
period: 'this_week',
|
||||
loading: false,
|
||||
result: '',
|
||||
error: ''
|
||||
@@ -80,11 +92,22 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPeriodLabel() {
|
||||
return this.weeklyReport.period === 'this_week' ? '本周' : '上周';
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
// 获取默认用户名
|
||||
await this.loadDefaultUser();
|
||||
},
|
||||
methods: {
|
||||
resetWeeklyReportResult() {
|
||||
this.weeklyReport.result = '';
|
||||
this.weeklyReport.error = '';
|
||||
},
|
||||
|
||||
async loadDefaultUser() {
|
||||
this.weeklyReport.username = resolveJiraDefaultQueryUser('');
|
||||
|
||||
@@ -118,7 +141,8 @@ export default {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.weeklyReport.username
|
||||
username: this.weeklyReport.username,
|
||||
period: this.weeklyReport.period
|
||||
})
|
||||
});
|
||||
|
||||
@@ -163,7 +187,8 @@ export default {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: this.weeklyReport.username
|
||||
username: this.weeklyReport.username,
|
||||
period: this.weeklyReport.period
|
||||
});
|
||||
|
||||
window.open(`/api/jira/weekly-report/download?${params}`, '_blank');
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
rows="8"
|
||||
placeholder="示例: X0X60F 17141\nC01008446046, 11894"
|
||||
:placeholder="currentTool.placeholder"
|
||||
class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0">
|
||||
<div class="flex flex-col gap-4 h-full min-h-0">
|
||||
<div>
|
||||
<div v-if="showQuerySql">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3>
|
||||
<button
|
||||
@@ -83,7 +83,50 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<div v-if="showSplitOutput" class="flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
|
||||
<div v-if="stats.total" class="text-xs text-gray-500">
|
||||
共 {{ stats.total }} 条,更新 {{ stats.update }} 条
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-rows-3 gap-3 flex-1 min-h-0">
|
||||
<div
|
||||
v-for="section in splitOutputSections"
|
||||
:key="section.key"
|
||||
class="flex flex-col min-h-0 border border-gray-200 rounded-lg overflow-hidden bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
|
||||
<div class="text-sm font-semibold text-gray-900">{{ section.label }}</div>
|
||||
<button
|
||||
@click="copySplitOutput(section.key)"
|
||||
:disabled="!splitOutputSql[section.key]"
|
||||
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-60"
|
||||
type="button"
|
||||
>
|
||||
复制{{ section.label }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="splitOutputSql[section.key]"
|
||||
readonly
|
||||
class="w-full flex-1 min-h-[120px] px-3 py-2 border-0 text-sm font-mono bg-gray-50 resize-none focus:ring-0"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<p class="text-xs text-gray-400">结果仅用于复制执行,请确认无误后使用</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="copyStatus.message"
|
||||
class="mt-2 text-xs"
|
||||
:class="copyStatus.type === 'success' ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ copyStatus.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
|
||||
<div v-if="stats.total" class="text-xs text-gray-500">
|
||||
@@ -143,6 +186,11 @@ export default {
|
||||
selectedTool: 'ob-external-id',
|
||||
inputText: '',
|
||||
outputSql: '',
|
||||
splitOutputSql: {
|
||||
sp: '',
|
||||
ppCn: '',
|
||||
ppUs: ''
|
||||
},
|
||||
querySql: '',
|
||||
loading: false,
|
||||
errors: [],
|
||||
@@ -161,7 +209,14 @@ export default {
|
||||
{
|
||||
value: 'ob-external-id',
|
||||
label: 'OB外部ID',
|
||||
description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。'
|
||||
description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。',
|
||||
placeholder: '示例: X0X60F 17141\nC01008446046, 11894'
|
||||
},
|
||||
{
|
||||
value: 'new-factory-return-redelivery',
|
||||
label: '新区工厂退库重新出库',
|
||||
description: '每行输入加工单、病例号、正确运单,只读取前三列;根据 CRM 加工单关联地址国家拆分 PP-CN / PP-US SQL。',
|
||||
placeholder: '示例: M20260508006905 225KF9 SF123456789\nM20260509005949 C01005934247 UPS987654321'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -169,12 +224,29 @@ export default {
|
||||
computed: {
|
||||
currentTool() {
|
||||
return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0];
|
||||
},
|
||||
|
||||
showSplitOutput() {
|
||||
return this.selectedTool === 'new-factory-return-redelivery';
|
||||
},
|
||||
|
||||
showQuerySql() {
|
||||
return !this.showSplitOutput;
|
||||
},
|
||||
|
||||
splitOutputSections() {
|
||||
return [
|
||||
{ key: 'sp', label: 'SP' },
|
||||
{ key: 'ppCn', label: 'PP-CN' },
|
||||
{ key: 'ppUs', label: 'PP-US' }
|
||||
];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearInput() {
|
||||
this.inputText = '';
|
||||
this.outputSql = '';
|
||||
this.resetSplitOutputSql();
|
||||
this.querySql = '';
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
@@ -249,6 +321,7 @@ export default {
|
||||
async generateSql() {
|
||||
this.errors = [];
|
||||
this.outputSql = '';
|
||||
this.resetSplitOutputSql();
|
||||
this.querySql = '';
|
||||
this.warnings = [];
|
||||
this.resetCopyStatus();
|
||||
@@ -259,6 +332,11 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedTool === 'new-factory-return-redelivery') {
|
||||
await this.generateNewFactoryReturnRedeliverySql();
|
||||
return;
|
||||
}
|
||||
|
||||
this.errors = ['未识别的功能类型,请重新选择。'];
|
||||
},
|
||||
|
||||
@@ -321,6 +399,113 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
parseNewFactoryReturnRedeliveryInput() {
|
||||
const lines = this.inputText.split(/\r?\n/);
|
||||
const errors = [];
|
||||
const rows = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/[\s,,]+/).filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
errors.push(`第 ${index + 1} 行格式不正确,请提供加工单、病例号、正确运单三列`);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
productionCode: parts[0],
|
||||
caseCode: parts[1],
|
||||
expressNo: parts[2],
|
||||
lineNumber: index + 1
|
||||
});
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push('请输入至少一行加工单、病例号、正确运单。');
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
errors
|
||||
};
|
||||
},
|
||||
|
||||
async generateNewFactoryReturnRedeliverySql() {
|
||||
const { rows, errors } = this.parseNewFactoryReturnRedeliveryInput();
|
||||
if (errors.length) {
|
||||
this.errors = errors;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const productionCodes = [...new Set(rows.map((row) => row.productionCode))];
|
||||
this.querySql = this.buildProductionCountriesSelect(productionCodes);
|
||||
const response = await fetch('/api/sql-generator/production-countries/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
production_codes: productionCodes
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
this.errors = [data.message || '查询 CRM 加工单国家失败,请稍后重试。'];
|
||||
return;
|
||||
}
|
||||
|
||||
const productionCountries = data.data.production_countries || {};
|
||||
const missingRows = rows.filter((row) => !productionCountries[row.productionCode] || productionCountries[row.productionCode].length === 0);
|
||||
if (missingRows.length) {
|
||||
this.errors = missingRows.map((row) => `第 ${row.lineNumber} 行加工单 ${row.productionCode} 未在 CRM 查询到地址国家`);
|
||||
return;
|
||||
}
|
||||
|
||||
const spSql = [];
|
||||
const ppCnSql = [];
|
||||
const ppUsSql = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const productionCode = this.escapeSqlValue(row.productionCode);
|
||||
const expressNo = this.escapeSqlValue(row.expressNo);
|
||||
const ppSql = this.isUsProductionCountry(productionCountries[row.productionCode]) ? ppUsSql : ppCnSql;
|
||||
|
||||
spSql.push(`update case_deliveries set express_no = '${expressNo}' where production_code = '${productionCode}';`);
|
||||
ppSql.push(`update delivery_records set express_no = '${expressNo}' where production_code = '${productionCode}';`);
|
||||
ppSql.push(`update receives set express_no = '${expressNo}' where production_code = '${productionCode}';`);
|
||||
});
|
||||
|
||||
const sections = [
|
||||
this.buildSqlSection('SP', spSql),
|
||||
this.buildSqlSection('PP-CN', ppCnSql),
|
||||
this.buildSqlSection('PP-US', ppUsSql)
|
||||
].filter(Boolean);
|
||||
|
||||
this.stats.update = spSql.length + ppCnSql.length + ppUsSql.length;
|
||||
this.stats.total = this.stats.update;
|
||||
this.splitOutputSql = {
|
||||
sp: spSql.join('\n'),
|
||||
ppCn: ppCnSql.join('\n'),
|
||||
ppUs: ppUsSql.join('\n')
|
||||
};
|
||||
this.outputSql = sections.join('\n\n');
|
||||
} catch (error) {
|
||||
this.errors = ['网络请求失败: ' + error.message];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
escapeSqlValue(value) {
|
||||
return String(value).replace(/'/g, "''");
|
||||
},
|
||||
@@ -334,6 +519,32 @@ export default {
|
||||
return `select * from case_extras where case_code in (${inValues})`;
|
||||
},
|
||||
|
||||
buildProductionCountriesSelect(productionCodes) {
|
||||
if (!productionCodes.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const inValues = productionCodes.map((productionCode) => `'${this.escapeSqlValue(productionCode)}'`).join(', ');
|
||||
return [
|
||||
'select ep.name, epc.ea_case_id_c, epc.ea_businessorder_id_c, epc.ea_salesorder_id_c',
|
||||
'from ea_production ep',
|
||||
'join ea_production_cstm epc on ep.id = epc.id_c',
|
||||
`where ep.deleted = 0 and ep.name in (${inValues})`
|
||||
].join('\n');
|
||||
},
|
||||
|
||||
isUsProductionCountry(countryCodes) {
|
||||
return (countryCodes || []).some((countryCode) => ['US', 'GU', 'PR'].includes(String(countryCode).toUpperCase()));
|
||||
},
|
||||
|
||||
buildSqlSection(title, sqlLines) {
|
||||
if (!sqlLines.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [`-- ${title}`, ...sqlLines].join('\n');
|
||||
},
|
||||
|
||||
buildDuplicateWarnings(duplicates) {
|
||||
return duplicates.map((duplicate) => {
|
||||
const lines = duplicate.lineNumbers.join('、');
|
||||
@@ -348,6 +559,14 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
resetSplitOutputSql() {
|
||||
this.splitOutputSql = {
|
||||
sp: '',
|
||||
ppCn: '',
|
||||
ppUs: ''
|
||||
};
|
||||
},
|
||||
|
||||
setCopyStatus(message, type) {
|
||||
this.copyStatus = {
|
||||
message,
|
||||
@@ -433,6 +652,10 @@ export default {
|
||||
await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea');
|
||||
},
|
||||
|
||||
async copySplitOutput(key) {
|
||||
await this.copyToClipboard(this.splitOutputSql[key], '复制失败,请手动复制结果。');
|
||||
},
|
||||
|
||||
async copyQuery() {
|
||||
await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea');
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ Route::prefix('env')->group(function () {
|
||||
// SQL 生成器 API 路由
|
||||
Route::prefix('sql-generator')->group(function () {
|
||||
Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']);
|
||||
Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']);
|
||||
});
|
||||
|
||||
// JIRA API路由
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SqlGeneratorTest extends TestCase
|
||||
{
|
||||
public function test_check_production_countries_requires_production_codes(): void
|
||||
{
|
||||
$response = $this->postJson('/api/sql-generator/production-countries/check', []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_check_production_countries_returns_country_codes_from_crm(): void
|
||||
{
|
||||
$connection = Mockery::mock();
|
||||
DB::shouldReceive('connection')
|
||||
->times(2)
|
||||
->with('crmslave')
|
||||
->andReturn($connection);
|
||||
|
||||
$productionBuilder = Mockery::mock();
|
||||
$productionBuilder->shouldReceive('join')
|
||||
->once()
|
||||
->with('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
|
||||
->andReturnSelf();
|
||||
$productionBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('ep.deleted', 0)
|
||||
->andReturnSelf();
|
||||
$productionBuilder->shouldReceive('whereIn')
|
||||
->once()
|
||||
->with('ep.name', ['M20260508006905'])
|
||||
->andReturnSelf();
|
||||
$productionBuilder->shouldReceive('select')
|
||||
->once()
|
||||
->with([
|
||||
'ep.name as production_code',
|
||||
'epc.ea_case_id_c',
|
||||
'epc.ea_businessorder_id_c',
|
||||
'epc.ea_salesorder_id_c',
|
||||
])
|
||||
->andReturnSelf();
|
||||
$productionBuilder->shouldReceive('get')
|
||||
->once()
|
||||
->andReturn(collect([
|
||||
(object) [
|
||||
'production_code' => 'M20260508006905',
|
||||
'ea_case_id_c' => '',
|
||||
'ea_businessorder_id_c' => '',
|
||||
'ea_salesorder_id_c' => 'sales-order-1',
|
||||
],
|
||||
]));
|
||||
|
||||
$salesOrderBuilder = Mockery::mock();
|
||||
$salesOrderBuilder->shouldReceive('join')
|
||||
->once()
|
||||
->with('accounts_ea_salesorder_1_c as aes1c', Mockery::type('Closure'))
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('join')
|
||||
->once()
|
||||
->with('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('join')
|
||||
->once()
|
||||
->with('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('es.deleted', 0)
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('a_base.deleted', 0)
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('whereIn')
|
||||
->once()
|
||||
->with('es.id', ['sales-order-1'])
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('whereNotNull')
|
||||
->once()
|
||||
->with('ac.country_c')
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('select')
|
||||
->once()
|
||||
->with([
|
||||
'es.id as entity_id',
|
||||
'ac.country_c as country',
|
||||
'ac.province_c as province',
|
||||
])
|
||||
->andReturnSelf();
|
||||
$salesOrderBuilder->shouldReceive('get')
|
||||
->once()
|
||||
->andReturn(collect([
|
||||
(object) [
|
||||
'entity_id' => 'sales-order-1',
|
||||
'country' => '840',
|
||||
'province' => '',
|
||||
],
|
||||
]));
|
||||
|
||||
$connection->shouldReceive('table')
|
||||
->once()
|
||||
->with('ea_production as ep')
|
||||
->andReturn($productionBuilder);
|
||||
$connection->shouldReceive('table')
|
||||
->once()
|
||||
->with('ea_salesorder as es')
|
||||
->andReturn($salesOrderBuilder);
|
||||
|
||||
$response = $this->postJson('/api/sql-generator/production-countries/check', [
|
||||
'production_codes' => ['M20260508006905'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'production_countries' => [
|
||||
'M20260508006905' => ['US'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
+169
-23
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Services\JiraService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JiraServiceTest extends TestCase
|
||||
{
|
||||
@@ -19,12 +20,19 @@ class JiraServiceTest extends TestCase
|
||||
'jira.host' => 'https://test-jira.example.com',
|
||||
'jira.username' => 'test-user',
|
||||
'jira.password' => 'test-password',
|
||||
'jira.default_user' => 'test-user'
|
||||
'jira.default_user' => 'test-user',
|
||||
]);
|
||||
|
||||
$this->jiraService = app(JiraService::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_is_task_completed_returns_false_for_incomplete_statuses()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
@@ -37,7 +45,7 @@ class JiraServiceTest extends TestCase
|
||||
'需求已评审',
|
||||
'In Progress',
|
||||
'To Do',
|
||||
'Open'
|
||||
'Open',
|
||||
];
|
||||
|
||||
foreach ($incompleteStatuses as $status) {
|
||||
@@ -59,7 +67,7 @@ class JiraServiceTest extends TestCase
|
||||
'Closed',
|
||||
'Resolved',
|
||||
'已完成',
|
||||
'Complete'
|
||||
'Complete',
|
||||
];
|
||||
|
||||
foreach ($completeStatuses as $status) {
|
||||
@@ -76,10 +84,11 @@ class JiraServiceTest extends TestCase
|
||||
$method = $reflection->getMethod('organizeTasksForReport');
|
||||
|
||||
$emptyWorkLogs = collect();
|
||||
$result = $method->invoke($this->jiraService, $emptyWorkLogs);
|
||||
$result = $method->invoke($this->jiraService, $emptyWorkLogs, 'test-user');
|
||||
|
||||
$this->assertInstanceOf(Collection::class, $result);
|
||||
$this->assertTrue($result->has('sprints'));
|
||||
$this->assertTrue($result->has('stories'));
|
||||
$this->assertTrue($result->has('tasks'));
|
||||
$this->assertTrue($result->has('bugs'));
|
||||
}
|
||||
@@ -100,10 +109,10 @@ class JiraServiceTest extends TestCase
|
||||
'bug_stage' => null,
|
||||
'bug_type' => null,
|
||||
'parent_task' => null,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->jiraService, $workLogs);
|
||||
$result = $method->invoke($this->jiraService, $workLogs, 'test-user');
|
||||
|
||||
$this->assertTrue($result['sprints']->has('十月中需求'));
|
||||
$this->assertCount(1, $result['sprints']['十月中需求']);
|
||||
@@ -124,28 +133,62 @@ class JiraServiceTest extends TestCase
|
||||
'sprint' => null,
|
||||
'bug_stage' => 'SIT环境BUG',
|
||||
'bug_type' => '需求未说明',
|
||||
'bug_description' => null,
|
||||
'parent_task' => null,
|
||||
]
|
||||
'assignee' => 'test-user',
|
||||
'developer' => null,
|
||||
'actual_fixer' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->jiraService, $workLogs);
|
||||
$result = $method->invoke($this->jiraService, $workLogs, 'test-user');
|
||||
|
||||
$this->assertTrue($result['bugs']->has('SIT环境BUG'));
|
||||
$this->assertCount(1, $result['bugs']['SIT环境BUG']);
|
||||
$this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']);
|
||||
}
|
||||
|
||||
public function test_resolve_weekly_report_range_for_last_week()
|
||||
{
|
||||
Carbon::setTestNow('2026-04-02 10:00:00');
|
||||
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('resolveWeeklyReportRange');
|
||||
|
||||
$result = $method->invoke($this->jiraService, 'last_week');
|
||||
|
||||
$this->assertEquals('2026-03-23 00:00:00', $result['start']->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('2026-03-29 23:59:59', $result['end']->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('上周完成的任务', $result['title']);
|
||||
}
|
||||
|
||||
public function test_resolve_weekly_report_range_for_this_week()
|
||||
{
|
||||
Carbon::setTestNow('2026-04-02 10:00:00');
|
||||
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('resolveWeeklyReportRange');
|
||||
|
||||
$result = $method->invoke($this->jiraService, 'this_week');
|
||||
|
||||
$this->assertEquals('2026-03-30 00:00:00', $result['start']->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('2026-04-02 23:59:59', $result['end']->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals('本周完成的任务', $result['title']);
|
||||
}
|
||||
|
||||
public function test_extract_sprint_info_from_string()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractSprintInfo');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'customfield_10020' => [
|
||||
'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]'
|
||||
]
|
||||
]
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'customFields' => [
|
||||
'customfield_10004' => [
|
||||
'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
@@ -157,10 +200,10 @@ class JiraServiceTest extends TestCase
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractBugStage');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'labels' => ['SIT', 'bug']
|
||||
]
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'labels' => ['SIT', 'bug'],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
@@ -172,13 +215,116 @@ class JiraServiceTest extends TestCase
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractBugType');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'labels' => ['需求未说明', 'bug']
|
||||
]
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'labels' => ['需求未说明', 'bug'],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
$this->assertEquals('需求未说明', $result);
|
||||
}
|
||||
|
||||
public function test_extract_developer_from_user_object()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractDeveloper');
|
||||
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'customFields' => [
|
||||
'customfield_11000' => (object) [
|
||||
'name' => 'zhangsan',
|
||||
'displayName' => '张三',
|
||||
'emailAddress' => 'zhangsan@example.com',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertEquals('zhangsan', $method->invoke($this->jiraService, $issue));
|
||||
}
|
||||
|
||||
public function test_extract_developer_from_associative_array()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractDeveloper');
|
||||
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'customFields' => [
|
||||
'customfield_11000' => [
|
||||
'name' => 'lisi',
|
||||
'displayName' => '李四',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertEquals('lisi', $method->invoke($this->jiraService, $issue));
|
||||
}
|
||||
|
||||
public function test_extract_actual_fixer_from_user_object()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractActualFixer');
|
||||
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'customFields' => [
|
||||
'customfield_11301' => (object) [
|
||||
'name' => 'wangwu',
|
||||
'displayName' => '王五',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertEquals('wangwu', $method->invoke($this->jiraService, $issue));
|
||||
}
|
||||
|
||||
public function test_extract_developer_returns_null_when_field_missing()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractDeveloper');
|
||||
|
||||
$issue = (object) [
|
||||
'fields' => (object) [
|
||||
'customFields' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertNull($method->invoke($this->jiraService, $issue));
|
||||
}
|
||||
|
||||
public function test_organize_tasks_for_report_includes_bug_when_user_is_developer_only()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('organizeTasksForReport');
|
||||
|
||||
// 模拟 WP-7158 场景:经办人是测试同学,开发人才是当前用户
|
||||
$workLogs = collect([
|
||||
[
|
||||
'issue_key' => 'WP-7158',
|
||||
'issue_summary' => '生产 Bug 修复',
|
||||
'issue_url' => 'https://test-jira.example.com/browse/WP-7158',
|
||||
'issue_status' => 'Done',
|
||||
'issue_type' => 'Bug',
|
||||
'sprint' => null,
|
||||
'bug_stage' => '生产环境BUG',
|
||||
'bug_type' => '代码错误',
|
||||
'bug_description' => null,
|
||||
'parent_task' => null,
|
||||
'assignee' => 'tester-user',
|
||||
'developer' => 'test-user',
|
||||
'actual_fixer' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->jiraService, $workLogs, 'test-user');
|
||||
|
||||
$this->assertTrue($result['bugs']->has('生产环境BUG'));
|
||||
$this->assertCount(1, $result['bugs']['生产环境BUG']);
|
||||
$this->assertEquals('WP-7158', $result['bugs']['生产环境BUG'][0]['key']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user