Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Admin/Backend] Goods list import feature #56

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
12 changes: 12 additions & 0 deletions packages/Common/src/data/goods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const goodsCsvHeader = [
"name",
"category_name",
"description",
"type",
"price",
"stock_initial",
] as const;

export const goodsCsvHeaderStringified = goodsCsvHeader.join(",");

export const goodsCsvHeaderMap = Object.freeze(Object.fromEntries(goodsCsvHeader.map((value) => [value, value])));
1 change: 1 addition & 0 deletions packages/Common/src/data/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./goods";
1 change: 1 addition & 0 deletions packages/Common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const APP_NAME: string = "Codename Sora";
export const DEVELOPER_TWITTER_HANDLE: string = "somni_somni";
export const HTTP_HEALTH_CHECK_STATUS_CODE: number = 239;

export * from "./data";
export * from "./enums";
export * from "./interfaces";
export * from "./utils";
8 changes: 8 additions & 0 deletions packages/Common/src/interfaces/goods.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IImageUploadInfo } from "./base";
import { IGoodsCategory } from "./goods-category";

/* === Common === */
export interface IGoodsCommon {
Expand Down Expand Up @@ -56,7 +57,14 @@ export interface IGoodsCreateRequest extends Omit<IGoodsBase, "id" | "combinatio
stockRemaining: number;
}
export interface IGoodsUpdateRequest extends Partial<Omit<IGoodsCreateRequest, "boothId">>, Pick<IGoodsCreateRequest, "boothId"> { }
export interface IGoodsCSVImportRequest {
csv: string;
}

/* === Responses === */
export interface IGoodsResponse extends IGoods { }
export interface IGoodsAdminResponse extends IGoodsAdmin { }
export interface IGoodsCSVImportPreviewResponse {
goods: Array<Pick<IGoodsAdmin, "name" | "categoryId" | "description" | "type" | "price" | "stock">>;
categories: Array<Pick<IGoodsCategory, "name">>;
}
110 changes: 110 additions & 0 deletions projects/Admin/src/components/dialogs/GoodsImportDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<template>
<CommonDialog v-model="open"
:progressActive="requestingPreview"
dialogTitle="굿즈 목록 가져오기"
dialogCancelText="취소"
dialogPrimaryText="미리보기"
:disablePrimary="!csvText && !selectedFile"
@primary="onDialogPrimary">
<p class="text-warning">굿즈의 목록만 가져올 수 있으며, 세트 구성 추가 및 이미지 업로드 등은 굿즈를 가져온 다음 개별적으로 진행해주세요.</p>

<VBtn class="mt-6 mb-2 w-100"
color="primary"
@click="downloadTemplate">CSV 탬플릿 다운로드</VBtn>
<p class="text-center text-disabled text-subtitle-2">
CSV 파일은
<a href="https://www.microsoft.com/microsoft-365/excel" target="_blank" style="color: currentColor">Microsoft Excel</a>,
<a href="https://docs.google.com/spreadsheets/" target="_blank" style="color: currentColor">Google Sheets</a>
등 스프레드시트 프로그램으로 편집할 수 있습니다.</p>
<p class="text-center text-info text-subtitle-2">CSV 파일을 저장할 때, <strong>반드시 UTF-8 인코딩으로 저장</strong>해주세요.</p>

<p class="mt-6">파일 업로드: <FileInputButton v-model="selectedFile" acceptsCustom="text/csv" /></p>
<VExpandTransition>
<VTextarea v-if="!selectedFile"
v-model="csvText"
:placeholder="csvTextPlaceholder"
autoGrow />
</VExpandTransition>
</CommonDialog>
</template>

<script lang="ts">
import { goodsCsvHeaderStringified } from "@myboothmanager/common";
import { Component, Model, Vue, Watch } from "vue-facing-decorator";
import { useAdminAPIStore } from "@/plugins/stores/api";
import FileInputButton from "../common/FileInputButton.vue";

@Component({
components: {
FileInputButton,
},
})
export default class GoodsImportDialog extends Vue {
@Model({ type: Boolean }) declare open: boolean;

requestingPreview = false;
selectedFile: File | null = null;
csvText: string = "";

get csvTextPlaceholder() {
return "또는 다음 첫 줄로 시작하는 CSV 형식의 파일 내용 직접 입력\n"
+ goodsCsvHeaderStringified;
}

@Watch("open", { immediate: true })
onDialogOpen() {
if(this.open) {
this.csvText = "";
this.selectedFile = null;
}
}

@Watch("selectedFile")
onFileSelect() {
if(this.selectedFile) {
this.csvText = "";

const reader = new FileReader();
reader.addEventListener("load", () => {
console.log(reader.result);
});
reader.readAsText(this.selectedFile, "UTF-8");
}
}

async onDialogPrimary() {
this.requestingPreview = true;

let csv: string = this.csvText;
if(this.selectedFile) {
let done = false;
const reader = new FileReader();
reader.addEventListener("load", () => {
csv = reader.result as string;
done = true;
});
reader.readAsText(this.selectedFile, "UTF-8");

while(!done) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}

const response = await useAdminAPIStore().requestPreviewGoodsCSVImport(csv);

console.log(response);
this.requestingPreview = false;
}

downloadTemplate() {
const csv = goodsCsvHeaderStringified + "\n";
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "goods_list_template.csv";
a.click();
URL.revokeObjectURL(url);
}
}
</script>
10 changes: 7 additions & 3 deletions projects/Admin/src/components/goods/GoodsManagePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@
min-width="64px"
size="x-large"
@click.stop="onLoadGoodsFromFileClick">
<VTooltip activator="parent" location="bottom">파일로부터 굿즈 목록 불러오기</VTooltip>
<VTooltip activator="parent" location="bottom">굿즈 목록 가져오기</VTooltip>
<VIcon>mdi-file-upload</VIcon>
</VBtn>
</VRow>
</DashboardPanel>

<GoodsManageDialog v-model="goodsAddDialogOpen" />
<GoodsCombinationManageDialog v-model="combinationAddDialogOpen" />
<GoodsImportDialog v-model="goodsImportDialogOpen" />
</template>

<script lang="ts">
Expand All @@ -50,22 +51,25 @@ import GoodsManageDialog from "@/components/dialogs/GoodsManageDialog.vue";
import { useAdminAPIStore } from "@/plugins/stores/api";
import DashboardPanel from "../dashboard/DashboardPanel.vue";
import GoodsCombinationManageDialog from "../dialogs/GoodsCombinationManageDialog.vue";
import GoodsImportDialog from "../dialogs/GoodsImportDialog.vue";

@Component({
components: {
DashboardPanel,
GoodsManageDialog,
GoodsCombinationManageDialog,
GoodsImportDialog,
},
})
export default class GoodsManagePanel extends Vue {
goodsAddDialogOpen = false;
combinationAddDialogOpen = false;
goodsImportDialogOpen = false;

goodsListRefreshing = false;

@Setup(() => useDisplay().smAndUp)
smAndUp!: boolean;
declare smAndUp: boolean;

get goodsCount(): string {
return Object.keys(useAdminStore().currentBooth.goods ?? {}).length.toLocaleString();
Expand All @@ -80,7 +84,7 @@ export default class GoodsManagePanel extends Vue {
}

onLoadGoodsFromFileClick(): void {
alert("준비 중인 기능입니다.");
this.goodsImportDialogOpen = true;
}
}
</script>
5 changes: 5 additions & 0 deletions projects/Admin/src/lib/api-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,9 @@ export default class AdminAPI extends BaseAdminAPI {
static async deleteGoodsCombinationImage(combinationId: number, boothId: number) {
return await this.apiCallWrapper<CT.ISuccessResponse>(() => this.API.DELETE(`goods/combination/${combinationId}/image?bId=${boothId}`));
}

/* Utility */
static async requestPreviewGoodsCSVImport(csv: string) {
return await this.apiCallWrapper<CT.IGoodsCSVImportPreviewResponse>(() => this.API.POST("goods/csv/preview", { csv } as CT.IGoodsCSVImportRequest));
}
}
2 changes: 2 additions & 0 deletions projects/Admin/src/pages/subpages/GoodsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export default class GoodsPage extends Vue {
deleteDialogOpen = false;
deleteDialogTarget: { isCombination: boolean, id: number } | null = null;

goodsImportDialogOpen = false;

get currencySymbol(): string {
return useAdminStore().currentBooth.booth!.currencySymbol;
}
Expand Down
12 changes: 12 additions & 0 deletions projects/Admin/src/plugins/stores/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,17 @@ const useAdminAPIStore = defineStore("admin-api", () => {
);
}

async function requestPreviewGoodsCSVImport(csv: string): Promise<C.IGoodsCSVImportPreviewResponse | C.ErrorCodes> {
let apiResponse: C.IGoodsCSVImportPreviewResponse | null = null;
const errorCode = await simplifyAPICall(
() => AdminAPI.requestPreviewGoodsCSVImport(csv),
(res) => apiResponse = res,
);

if(typeof errorCode === "number") return errorCode;
return apiResponse ?? C.ErrorCodes.UNKNOWN_ERROR;
}

/* Goods Combination */
async function fetchGoodsCombinationsOfCurrentBooth(): Promise<true | C.ErrorCodes> {
return await simplifyAPICall(
Expand Down Expand Up @@ -446,6 +457,7 @@ const useAdminAPIStore = defineStore("admin-api", () => {
uploadGoodsImage,
deleteGoodsImage,
deleteGoods,
requestPreviewGoodsCSVImport,

fetchGoodsCombinationsOfCurrentBooth,
createGoodsCombination,
Expand Down
2 changes: 2 additions & 0 deletions projects/Backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"chalk": "^4",
"class-transformer": "^0.5.1",
"clone-deep": "^4.0.1",
"csv-parse": "^5.5.6",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"fastify": "=4.27.0",
Expand All @@ -60,6 +61,7 @@
"@nestjs/testing": "^10.3.9",
"@tsconfig/node20": "^20.1.4",
"@types/clone-deep": "^4.0.4",
"@types/csv-parse": "^1.2.2",
"@types/jest": "^29.5.12",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.7",
Expand Down
18 changes: 18 additions & 0 deletions projects/Backend/src/modules/admin/goods/dto/import-goods.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Exclude, Expose } from "class-transformer";
import { IGoodsCSVImportPreviewResponse, IGoodsCSVImportRequest } from "@myboothmanager/common";

export class GoodsImportRequestDto implements IGoodsCSVImportRequest {
declare csv: string;
}

@Exclude()
export class GoodsImportPreviewResponseDto implements IGoodsCSVImportPreviewResponse {
@Expose() declare goods: IGoodsCSVImportPreviewResponse["goods"];
@Expose() declare categories: IGoodsCSVImportPreviewResponse["categories"];

constructor(goods: IGoodsCSVImportPreviewResponse["goods"],
categories: IGoodsCSVImportPreviewResponse["categories"]) {
this.goods = goods;
this.categories = categories;
}
}
9 changes: 9 additions & 0 deletions projects/Backend/src/modules/admin/goods/goods.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { FastifyRequest } from "fastify";
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseGuards, UseInterceptors, ClassSerializerInterceptor } from "@nestjs/common";
import { PublicGoodsService } from "@/modules/public/goods/goods.service";
import { InvalidRequestBodyException } from "@/lib/exceptions";
import { AuthData, AdminAuthGuard, SuperAdmin } from "../auth/auth.guard";
import { IAuthPayload } from "../auth/jwt";
import { UtilService } from "../util/util.service";
import { GoodsService } from "./goods.service";
import { CreateGoodsRequestDto } from "./dto/create-goods.dto";
import { UpdateGoodsRequestDto } from "./dto/update-goods.dto";
import { AdminGoodsResponseDto } from "./dto/goods.dto";
import { GoodsImportPreviewResponseDto } from "./dto/import-goods.dto";

@UseGuards(AdminAuthGuard)
@Controller("/admin/goods")
Expand Down Expand Up @@ -45,6 +47,13 @@ export class GoodsController {
return await this.goodsService.remove(+id, +boothId, authData.id);
}

@Post("csv/preview")
async previewCSVImport(@Body("csv") csv: string): Promise<GoodsImportPreviewResponseDto> {
if(!csv || csv.length <= 0) throw new InvalidRequestBodyException();

return await this.goodsService.previewCSVImport(csv);
}

/* SuperAdmin routes */
@SuperAdmin()
@UseInterceptors(ClassSerializerInterceptor)
Expand Down
43 changes: 41 additions & 2 deletions projects/Backend/src/modules/admin/goods/goods.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import type { MultipartFile } from "@fastify/multipart";
import { Injectable } from "@nestjs/common";
import { ISuccessResponse, ISingleValueResponse, ImageSizeConstraintKey, IImageUploadInfo } from "@myboothmanager/common";
import { ISuccessResponse, ISingleValueResponse, ImageSizeConstraintKey, IImageUploadInfo, GoodsStockVisibility } from "@myboothmanager/common";
import Goods from "@/db/models/goods";
import Booth from "@/db/models/booth";
import { create, findOneByPk, removeTarget } from "@/lib/common-functions";
import { EntityNotFoundException, NoAccessException } from "@/lib/exceptions";
import { UtilService } from "../util/util.service";
import { CSVService } from "../util/csv.service";
import { UpdateGoodsRequestDto } from "./dto/update-goods.dto";
import { CreateGoodsRequestDto } from "./dto/create-goods.dto";
import { GoodsInfoUpdateFailedException, GoodsParentBoothNotFoundException } from "./goods.exception";
import { GoodsImportPreviewResponseDto } from "./dto/import-goods.dto";

@Injectable()
export class GoodsService {
constructor(private readonly utilService: UtilService) { }
constructor(
private readonly utilService: UtilService,
private readonly csvService: CSVService,
) { }

private async getGoodsAndParentBooth(goodsId: number, boothId: number, callerAccountId: number): Promise<{ goods: Goods, booth: Booth }> {
const goods = await findOneByPk(Goods, goodsId);
Expand Down Expand Up @@ -108,4 +113,38 @@ export class GoodsService {

return await removeTarget(goods);
}

async previewCSVImport(csv: string): Promise<GoodsImportPreviewResponseDto> {
const parsed = await this.csvService.parseCSVString(csv/*, goodsCsvHeader as unknown as Array<string>*/);
console.log(parsed);

// Extract categories first
const categories: GoodsImportPreviewResponseDto["categories"] = [];
for(const record of parsed) {
const category: string = record["category_name"];
if(category && categories.findIndex((c) => c.name === category) < 0) {
categories.push({ name: category });
}
}

// Then goods
const goods: GoodsImportPreviewResponseDto["goods"] = [];
for(const record of parsed) {
const categoryId = categories.findIndex((c) => c.name === record["category_name"]);

goods.push({
categoryId: categoryId >= 0 ? categoryId : undefined,
name: record["name"],
description: record["description"],
price: record["price"],
stock: {
initial: record["stock_initial"],
remaining: record["stock_initial"],
visibility: GoodsStockVisibility.SHOW_ALL,
},
});
}

return new GoodsImportPreviewResponseDto(goods, categories);
}
}
Loading
Loading