|
|
|
|
@ -291,3 +291,173 @@ Nhánh "Đã xóa" gọi với `page: 1, pageSize: 50` cố định, không có
|
|
|
|
|
**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:557-574` (và bản gương fe-user)
|
|
|
|
|
|
|
|
|
|
DTO dùng chung với danh sách sống nên không có `deletedAt` và `deletedBy`; màn hình lấy `updatedAt` làm xấp xỉ, có ký hiệu "≈" và chú giải khi rê chuột. Cách xử lý trung thực, chấp nhận được. Rủi ro còn lại: nếu về sau có đường ghi nào chạm vào phiếu đã xóa (ví dụ một thao tác quản trị), mốc hiển thị sẽ nhảy mà không ai biết. Muốn chắc thì bổ sung `deletedAt` và `deletedBy` vào DTO riêng của màn này.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# 🔁 SOI LẠI GIỮA LƯỢT — mã đã đổi sau khi tôi bắt đầu (3 thay đổi của lead, chưa ai soi)
|
|
|
|
|
|
|
|
|
|
Toàn bộ phần dưới đây đo trên **trạng thái đĩa hiện tại**, không phải bản lúc tôi mở lượt.
|
|
|
|
|
|
|
|
|
|
## R1. `PurchaseEvaluationsController.cs` — gỡ policy khỏi `DeleteByApprover`: ✅ ĐÚNG, **H2 ĐÓNG**
|
|
|
|
|
|
|
|
|
|
Đọc lại `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:159-183`: thuộc tính `[Authorize(Policy = "PurchaseEvaluations.Delete")]` **đã không còn**; action chỉ còn `[HttpDelete("{id:guid}/by-approver")]` và thừa hưởng `[Authorize]` trần ở cấp lớp (`:15`). Khối chú thích kèm theo nêu đúng số đo và đúng hệ quả, kể cả câu dặn "FE không được gate lại". **H2 coi như đã đóng.**
|
|
|
|
|
|
|
|
|
|
### Gỡ vậy có mở lỗ ghi nào không? — **Không.** Truy đúng từng dòng:
|
|
|
|
|
|
|
|
|
|
Kịch bản: một tài khoản đăng nhập bất kỳ, không phải người duyệt, gọi thẳng `DELETE /api/purchase-evaluations/{id}/by-approver`.
|
|
|
|
|
|
|
|
|
|
| Chặng | Dòng chặn | Kết quả |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| Chưa đăng nhập | `Controller:15` `[Authorize]` | 401 |
|
|
|
|
|
| Không có `UserId` trong token | `PeSoftDeleteFeatures.cs:56-57` | `UnauthorizedException` |
|
|
|
|
|
| Phiếu không tồn tại / đã xóa | `:60-61` (bộ lọc toàn cục vẫn áp) | `NotFoundException` |
|
|
|
|
|
| Phiếu không ở `ChoDuyet` | `:65-68` | `ConflictException` |
|
|
|
|
|
| Phiếu ghim quy trình V1 | `:70-72` | `ConflictException` |
|
|
|
|
|
| **Không phải người duyệt của Cấp đang tới lượt** | **`:104-107`** | **`ForbiddenException`** ← đây là chốt chính |
|
|
|
|
|
| Đúng người nhưng chưa được tích cờ | `:114-117` | `ForbiddenException` |
|
|
|
|
|
|
|
|
|
|
**Câu hỏi then chốt: có đường `.First()` nào khiến người ngoài đọc trúng cờ của người khác không?** Tôi đã cảnh báo đúng cơ chế này ở phía FE, nên phải kiểm phía BE cho chắc. **Không có.** Dòng `:104` viết:
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)
|
|
|
|
|
?? throw new ForbiddenException(...);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Toán tử `??` dẫn thẳng tới `throw`, **không** dẫn tới `pendingLevelGroup.First()`. Đây khác hẳn `PurchaseEvaluationWorkflowService.cs:744-745` (đường admin-ký-thay khi duyệt) và khác `PurchaseEvaluationFeatures.cs:1084-1085` (đường đọc để hiển thị, có fallback về dòng đầu). Nghĩa là cờ F6 luôn được đọc trên **đúng dòng của chính người gọi**, hoặc không đọc gì cả.
|
|
|
|
|
|
|
|
|
|
**Thử phá thêm:** tìm mọi lối tắt cho Admin trong tệp — `grep "Admin" PeSoftDeleteFeatures.cs` cho đúng **một** lần dùng `AppRoles.Admin`, nằm ở handler **danh sách** (`:200`), không nằm ở handler xóa. Vậy một Admin không phải người duyệt hiện tại vẫn dừng ở `:104`. Đúng chủ ý.
|
|
|
|
|
|
|
|
|
|
**Kết luận về mặt ghi dữ liệu:** ba rào chặt hơn tầng policy vừa gỡ, vì chúng đòi **danh tính đích danh** chứ không đòi **vai**. Việc gỡ policy không mở thêm bất kỳ đường ghi nào.
|
|
|
|
|
|
|
|
|
|
### Nhưng có một cái mở ra, nhỏ và cần nói: **L6 (LOW) — endpoint nay thành máy dò cho mọi tài khoản đăng nhập**
|
|
|
|
|
|
|
|
|
|
Trước thay đổi, chỉ `Admin` và `DeptManager` chạm được tới thân handler; nay bất kỳ ai đăng nhập cũng chạm được. Thân handler trả về **bốn loại kết quả phân biệt được**: không tìm thấy, sai trạng thái, quy trình V1, chưa tới lượt. Riêng thông điệp ở `:106-107` còn kèm **tên Bước** và **số Cấp**:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: chưa tới lượt duyệt của bạn..."
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Nghĩa là một tài khoản bất kỳ, nếu biết mã định danh của phiếu, có thể suy ra: phiếu có tồn tại không, đang ở trạng thái nào, có ghim quy trình V2 không, đang dừng ở Bước nào tên gì và Cấp mấy. Không ghi được gì, chỉ đọc rò.
|
|
|
|
|
|
|
|
|
|
Mức LOW vì mã định danh là GUID nên không dò mò được, và các thông tin trên đều là siêu dữ liệu quy trình chứ không phải số tiền. **Đề xuất:** bỏ `{currentStep.Name}` khỏi thông điệp và gộp nhánh "không tìm thấy" với nhánh "chưa tới lượt" thành một câu trả lời chung, nếu muốn khép hẳn.
|
|
|
|
|
|
|
|
|
|
## R2. `PeWorkflowPanel.tsx` hai ứng dụng — gỡ gate theo vai: ✅ ĐÚNG, khớp 1-1
|
|
|
|
|
|
|
|
|
|
Đọc lại `fe-admin/src/components/pe/PeWorkflowPanel.tsx:130-140`. Điều kiện hiện nút nay còn đúng bốn vế:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
!readOnly
|
|
|
|
|
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
|
|
|
|
&& actorIsCurrentApprover
|
|
|
|
|
&& levelOptions?.allowApproverDelete === true
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`can(...)` đã biến mất khỏi biểu thức. Quét dấu vết còn sót của ba thứ lẽ ra phải gỡ (`usePermission`, `MenuKeys`, `can(`): chỉ còn **một** lần xuất hiện ở mỗi ứng dụng, và nó nằm trong **dòng chú thích** `:134` giải thích vì sao cố ý không dùng — không phải mã sống. Không còn lệnh nhập thừa, khớp với việc `tsc` sạch.
|
|
|
|
|
|
|
|
|
|
**md5 gương — tôi tự đo lại, không lấy số của lead:**
|
|
|
|
|
```
|
|
|
|
|
7cd6bbfe73e739633582fc47d710e3e1 fe-admin/src/components/pe/PeWorkflowPanel.tsx
|
|
|
|
|
7cd6bbfe73e739633582fc47d710e3e1 fe-user/src/components/pe/PeWorkflowPanel.tsx
|
|
|
|
|
```
|
|
|
|
|
**KHỚP**, và trùng đúng chuỗi lead báo.
|
|
|
|
|
|
|
|
|
|
**Đối chiếu 1-1 với rào BE — có rào nào lệch không:**
|
|
|
|
|
|
|
|
|
|
| Rào BE | Vế FE tương ứng | Đánh giá |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `:65` phase `ChoDuyet` | `evaluation.phase === ChoDuyet` | khớp |
|
|
|
|
|
| `:70-72` phải ghim quy trình V2 | `actorIsCurrentApprover` dựng từ `currentApproval`, mà trường này **null** khi phiếu ghim V1 | khớp gián tiếp, đúng chiều |
|
|
|
|
|
| `:104` đúng lượt | `actorIsCurrentApprover` (cố ý không dùng `actorInV2Level` vì biến đó có `isAdmin ||`) | khớp |
|
|
|
|
|
| `:114` cờ F6 per-người | `levelOptions?.allowApproverDelete === true` | **về hình thức thì khớp, nhưng vế này không bao giờ đúng — xem H1** |
|
|
|
|
|
| (không có ở BE) | `!readOnly` | FE **chặt hơn** BE: chỉ hiện nút ở mặt duyệt. Lệch theo chiều an toàn, chấp nhận được |
|
|
|
|
|
|
|
|
|
|
Không có rào BE nào bị FE bỏ sót (tức không có cảnh "thấy nút rồi ăn lỗi"). Chiều ngược lại — "ẩn nút với người có quyền" — thì **đang xảy ra**, nhưng nguyên nhân không phải gate theo vai nữa mà là **H1**.
|
|
|
|
|
|
|
|
|
|
## R3. `PeDeleteByApproverTests.cs` T25 viết lại: ✅ ĐÚNG LÀ ĐỔI SPEC, và vẫn còn răng
|
|
|
|
|
|
|
|
|
|
**Có phải nới assert không? Không.** Khẳng định cũ là `Policy == "PurchaseEvaluations.Delete"`; khẳng định mới là `GetActionAuthorize(...) == null`. Hai mệnh đề này **loại trừ nhau** — không có bản mã nào thoả cả hai. Nới assert là khi mệnh đề mới **được suy ra** từ mệnh đề cũ (kiểu đổi `== "X"` thành `!= null`); ở đây thì ngược lại, mệnh đề mới bác bỏ mệnh đề cũ. Đó là **đổi hợp đồng**, và tên hàm cũng đổi theo (`RequiresDeletePolicy` thành `MustStayPolicyFree`) nên không có chuyện tên nói một đằng khẳng định một nẻo. Đúng cách làm.
|
|
|
|
|
|
|
|
|
|
**Còn răng không?** Có. Nếu ai đó "siết bảo mật" bằng cách gắn lại `[Authorize(Policy = ...)]` lên `DeleteByApprover`, hàm `GetActionAuthorize` trả về đối tượng khác null, `BeNull()` đỏ ngay. Đúng cái nó hứa ở dòng `:649-650`.
|
|
|
|
|
|
|
|
|
|
**Một hụt nhỏ do viết lại, ghi để không quên:** bản cũ có thêm hai khẳng định tách `Policy` ra rồi kiểm `parts[0] ∈ MenuKeys.All` và `parts[1] ∈ MenuKeys.Actions` — đó là chốt chặn cho lớp lỗi "gắn tên policy chưa đăng ký thì test thuộc tính vẫn xanh mà chạy thật thì ném". Bản mới bỏ hai khẳng định đó, và hiện **không ca nào** kiểm điều đó cho `PurchaseEvaluations.Read` của `ListDeleted`. Hiện tại vô hại vì `PurchaseEvaluations` có trong `MenuKeys.All` (tôi đã đếm: 54 phần tử, có mặt). Nhưng nên khôi phục phép kiểm đó cho nhánh `ListDeleted` — gộp vào **M3**.
|
|
|
|
|
|
|
|
|
|
## R4. Số đo — tôi tự chạy lại, không dùng số của lead
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
dotnet test SolutionErp.slnx --nologo
|
|
|
|
|
→ SolutionErp.Domain.Tests : Failed 0, Passed 45
|
|
|
|
|
→ SolutionErp.Infrastructure.Tests: Failed 0, Passed 516 (59 s)
|
|
|
|
|
tổng 561 PASS / 0 FAIL
|
|
|
|
|
```
|
|
|
|
|
Khớp con số lead báo. Trong đó lớp `PeDeleteByApproverTests` đóng góp 10 ca, đã chạy riêng trước đó cũng 10/10.
|
|
|
|
|
|
|
|
|
|
## R5. 🔴 **H1 VẪN CÒN NGUYÊN — và nay nó là chốt chặn DUY NHẤT**
|
|
|
|
|
|
|
|
|
|
Đo lại trên đĩa sau ba thay đổi:
|
|
|
|
|
|
|
|
|
|
- `PurchaseEvaluationDtos.cs:123-131` — `ApprovalWorkflowOptionsDto` vẫn đúng **8** thành viên, **không** có `AllowApproverDelete`.
|
|
|
|
|
- `PurchaseEvaluationFeatures.cs:1088-1097` — site dựng duy nhất vẫn truyền **8** đối số.
|
|
|
|
|
- `grep -c "AllowApproverDelete" PurchaseEvaluationFeatures.cs` → **0**.
|
|
|
|
|
|
|
|
|
|
Vế thứ tư của FE, `levelOptions?.allowApproverDelete === true`, vì vậy vẫn luôn cho ra sai. **Nút "Xóa phiếu" vẫn không bao giờ hiện với bất kỳ ai.**
|
|
|
|
|
|
|
|
|
|
Việc gỡ policy đã dọn xong chướng ngại thứ hai, nhưng chướng ngại thứ nhất còn nguyên, mà nó nằm **trước** trong chuỗi: người dùng chưa bao giờ nhìn thấy nút để mà bấm. Sửa gọn trong hai dòng: thêm `bool AllowApproverDelete` vào cuối `ApprovalWorkflowOptionsDto` và truyền `curLevel.AllowApproverDelete` ở `PurchaseEvaluationFeatures.cs:1097`.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# Những điểm làm ĐÚNG, đáng giữ nguyên
|
|
|
|
|
|
|
|
|
|
1. **Tách đường xóa nháp và đường xóa khi đang duyệt thành hai endpoint riêng.** Quyết định kiến trúc đúng, và đã cứu một hồi quy thật: gộp chung rồi gắn policy thì vai `Drafter` mất quyền xóa nháp của chính mình. T25 khoá lại bằng phản chứng.
|
|
|
|
|
2. **Đọc cờ F6 trên đúng dòng vừa khớp người, không dùng `Any` trên cả Cấp.** Một Cấp có nhiều người; đọc bằng `Any` sẽ phát quyền huỷ chứng-từ tài chính cho cả nhóm. T24b là ca duy nhất phân biệt được hai cách đọc, và nó tồn tại.
|
|
|
|
|
3. **Cố ý không cho Admin đi tắt ở đường xóa**, dù khuôn admin-ký-thay nằm ngay bên cạnh. Sau khi gỡ policy, đây trở thành lớp bảo vệ chính, và nó đứng vững.
|
|
|
|
|
4. **Không đụng `PeBudgetAccumulator` một dòng nào.** Hiệu quả "xóa rồi thôi ăn lũy kế" đến từ bộ lọc toàn cục sẵn có — cách sửa rẻ nhất, ít rủi ro nhất. T21 chứng minh bốn đại lượng đi bốn hướng khác nhau chứ không phải cùng giảm.
|
|
|
|
|
5. **Chất lượng bộ test cao hơn mức thường thấy:** mỗi ca phủ định đều có ca thuận đi kèm, mỗi phép đo "không đổi" đều được seed cho khác 0, ca IDOR có hai đối chứng. Bộ này bắt được lớp "xanh vì rỗng" mà dự án từng vấp nhiều lần.
|
|
|
|
|
6. **Cách xử lý phản hồi review cũng đúng:** đổi hợp đồng thì đổi luôn tên ca test và ghi rõ lý do ngay tại chỗ, thay vì lặng lẽ nới khẳng định. Đây là chi tiết nhỏ nhưng là khác biệt giữa "sửa" và "làm cho hết đỏ".
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# Thao tác commit — chỗ dễ bỏ sót, nói rõ trước khi bấm
|
|
|
|
|
|
|
|
|
|
Đợt 1 từng vấp F8: tệp migration chưa được theo dõi bị `commit -a` bỏ lại. Đợt 2 **không có migration**, nhưng hình dạng rủi ro chỉ đổi chứ chưa biến mất.
|
|
|
|
|
|
|
|
|
|
**Hai tệp đang ở trạng thái chưa được theo dõi:**
|
|
|
|
|
```
|
|
|
|
|
?? src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs
|
|
|
|
|
?? tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- `git commit -a` **không** đưa tệp chưa theo dõi vào commit. Phải `git add` đích danh hai đường dẫn trên.
|
|
|
|
|
- Đã kiểm `git check-ignore -v` cho cả hai: **không** bị quy tắc bỏ qua nào chặn (mã thoát 1), nên `git add` sẽ nhận, không cần `-f`.
|
|
|
|
|
- **Nếu quên `PeSoftDeleteFeatures.cs`:** lần này hỏng **to tiếng** — `PurchaseEvaluationsController.cs` tham chiếu `DeletePurchaseEvaluationByApproverCommand` và `ListDeletedPurchaseEvaluationsQuery`, hai kiểu chỉ tồn tại trong tệp đó, nên CI đỏ ngay ở bước biên dịch. Khác F8 ở chỗ này, và là khác theo hướng tốt.
|
|
|
|
|
- **Nếu quên `PeDeleteByApproverTests.cs`:** hỏng **im lặng** — kho vẫn biên dịch, CI vẫn xanh, chỉ mất trọn 10 ca vừa viết, trong đó T26 là chốt chặn duy nhất cho lớp lỗi mất vết và T25 là chốt chặn duy nhất chống việc gắn lại policy. **Đây mới là cái cần canh.**
|
|
|
|
|
- Sau khi `git add`, kiểm lại `git status --porcelain` và xác nhận **không còn dòng nào bắt đầu bằng `??`** trong `src/` hay `tests/` trước khi commit.
|
|
|
|
|
- Ngoài phạm vi mã còn có `.claude/WAL.md`, `.claude/governance/.session-counter.json`, `.claude/agent-memory/test-specialist/MEMORY.md` và các tệp trong thư mục run. Tách hay gộp là quyền của lead, nhưng đừng để chúng che mất hai dòng `??` ở trên khi đọc `git status`.
|
|
|
|
|
|
|
|
|
|
**Một việc bắt buộc sau khi deploy:** đợt này **không có migration nhưng có seed menu mới**, nên phải **khởi động lại API** thì `DbInitializer` mới chạy và mới có ba thứ: hai mục menu mới, việc đổi nhãn "Duyệt" thành "Đang duyệt", và các hàng quyền cho hai khoá mới. Không khởi động lại thì menu y như cũ và owner sẽ báo "chưa thấy gì".
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# Kết luận
|
|
|
|
|
|
|
|
|
|
**Tổng 12 FLAG — 2 HIGH (1 đã đóng ngay trong lượt), 4 MED, 6 LOW. Còn mở: 11.**
|
|
|
|
|
|
|
|
|
|
| Mã | Mức | Trạng thái | Một câu |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| H1 | HIGH | 🔴 **CÒN MỞ** | FE gate nút xóa bằng `allowApproverDelete`, mà BE không có trường đó trong DTO nên nút không bao giờ hiện. |
|
|
|
|
|
| H2 | HIGH | ✅ ĐÃ ĐÓNG | Policy `PurchaseEvaluations.Delete` chặn đúng nhóm người duyệt — owner chọn phương án gỡ policy; đã kiểm lại và đúng, không mở lỗ ghi nào. |
|
|
|
|
|
| M1 | MED | mở | Chú thích khẳng định `IgnoreQueryFilters` là nơi duy nhất trong `src/Backend`; thực tế 17 lần dùng ở 2 tệp. Vẫn còn ở `PeSoftDeleteFeatures.cs:178` và `Controller:173`. |
|
|
|
|
|
| M2 | MED | mở | Chú thích T26 vẫn khai "ca này đang đỏ, bug production" trong khi ca đã xanh; mốc dòng cũng trôi. |
|
|
|
|
|
| M3 | MED | mở | Thiếu lớp test ràng buộc hợp đồng BE với FE — chính lỗ này để H1 sống qua 561 ca xanh. Kèm hụt mới: T25 viết lại đã bỏ phép kiểm "tên policy có được đăng ký". |
|
|
|
|
|
| M4 | MED | mở | Nhiều mốc `:NNN` trỏ sai vị trí thật; nay thêm hai câu chú thích đã lạc hậu sau khi gỡ policy (`PeWorkflowPanel.tsx:117` còn nhắc "tầng authz độc lập", `Controller:147` dẫn `DbInitializer.cs:2515` trong khi chỗ thật là `:2534`). |
|
|
|
|
|
| L1 | LOW | mở | Chú thích giải thích bản vá thứ tự bằng mô hình sai một nhịp (lead đã nhận). |
|
|
|
|
|
| L2 | LOW | mở | `IgnoreQueryFilters` có phạm vi cả truy vấn nên gỡ luôn bộ lọc của các bảng được nối vào. |
|
|
|
|
|
| L3 | LOW | mở | Màn "Đã xóa" cố định 50 dòng, không phân trang, không giới hạn phía máy chủ — kế thừa khuyết tật sẵn có. |
|
|
|
|
|
| L4 | LOW | mở | Nút xóa cũng sống ở trang chi tiết toàn màn, tức rộng hơn phát biểu "chỉ ở màn duyệt". |
|
|
|
|
|
| L5 | LOW | mở | Mốc "xóa ≈" lấy từ `updatedAt`, không phải mốc xóa thật. |
|
|
|
|
|
| L6 | LOW | mở | **Mới, sinh ra từ việc gỡ policy:** mọi tài khoản đăng nhập nay chạm được thân handler và đọc được bốn loại phản hồi phân biệt, kèm tên Bước trong thông điệp lỗi. Không ghi được gì, chỉ rò siêu dữ liệu. |
|
|
|
|
|
|
|
|
|
|
Phần lõi nghiệp vụ đã đúng và đã được đo: ba rào per-người, ghi vết, xóa mềm, lũy kế, rào IDOR, cách ly `IgnoreQueryFilters`. Việc gỡ policy hôm nay được thực hiện đúng và đủ — tôi đã truy từng dòng chặn và xác nhận không có `.First()` nào cho người ngoài mượn cờ của người khác. Nhưng còn **đúng một** chỗ đứt: cờ F6 chưa bao giờ đi được từ cơ sở dữ liệu ra tới trình duyệt. Chừng nào chưa nối, owner mở màn duyệt sẽ không thấy nút, và buổi nghiệm thu kết thúc ở câu "chẳng thấy gì".
|
|
|
|
|
|
|
|
|
|
**ĐỪNG-DEPLOY** — chỉ còn đúng một chốt chặn H1 (thêm `AllowApproverDelete` vào DTO và truyền nó ở `PurchaseEvaluationFeatures.cs:1097`, hai dòng); nối xong hai dòng đó rồi đẩy thì tôi đổi sang DEPLOY-OK ngay, vì mọi trục còn lại đã đo và đứng.
|
|
|
|
|
|
|
|
|
|
<!-- END reviewer-diff-dot2 · TOTAL=12 FLAG -->
|
|
|
|
|
|